diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 28ba573af..000000000 --- a/.editorconfig +++ /dev/null @@ -1,1396 +0,0 @@ -[*] -charset = utf-8 -end_of_line = lf -indent_size = 2 -indent_style = space -insert_final_newline = true -max_line_length = 120 -tab_width = 8 -ij_continuation_indent_size = 4 -ij_formatter_off_tag = @formatter:off -ij_formatter_on_tag = @formatter:on -ij_formatter_tags_enabled = false -ij_smart_tabs = false -ij_visual_guides = none -ij_wrap_on_typing = false - -[*.css] -ij_css_align_closing_brace_with_properties = false -ij_css_blank_lines_around_nested_selector = 1 -ij_css_blank_lines_between_blocks = 1 -ij_css_brace_placement = end_of_line -ij_css_enforce_quotes_on_format = false -ij_css_hex_color_long_format = false -ij_css_hex_color_lower_case = false -ij_css_hex_color_short_format = false -ij_css_hex_color_upper_case = false -ij_css_keep_blank_lines_in_code = 2 -ij_css_keep_indents_on_empty_lines = false -ij_css_keep_single_line_blocks = false -ij_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 -ij_css_space_after_colon = true -ij_css_space_before_opening_brace = true -ij_css_use_double_quotes = true -ij_css_value_alignment = do_not_align - -[*.feature] -ij_continuation_indent_size = 8 -ij_gherkin_keep_indents_on_empty_lines = false - -[*.gsp] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_gsp_keep_indents_on_empty_lines = false - -[*.haml] -ij_continuation_indent_size = 8 -ij_haml_keep_indents_on_empty_lines = false - -[*.java] -ij_java_align_consecutive_assignments = false -ij_java_align_consecutive_variable_declarations = false -ij_java_align_group_field_declarations = false -ij_java_align_multiline_annotation_parameters = false -ij_java_align_multiline_array_initializer_expression = false -ij_java_align_multiline_assignment = false -ij_java_align_multiline_binary_operation = false -ij_java_align_multiline_chained_methods = false -ij_java_align_multiline_extends_list = false -ij_java_align_multiline_for = false -ij_java_align_multiline_method_parentheses = false -ij_java_align_multiline_parameters = false -ij_java_align_multiline_parameters_in_calls = false -ij_java_align_multiline_parenthesized_expression = false -ij_java_align_multiline_records = true -ij_java_align_multiline_resources = false -ij_java_align_multiline_ternary_operation = false -ij_java_align_multiline_text_blocks = false -ij_java_align_multiline_throws_list = false -ij_java_align_subsequent_simple_methods = false -ij_java_align_throws_keyword = false -ij_java_annotation_parameter_wrap = off -ij_java_array_initializer_new_line_after_left_brace = false -ij_java_array_initializer_right_brace_on_new_line = false -ij_java_array_initializer_wrap = normal -ij_java_assert_statement_colon_on_next_line = false -ij_java_assert_statement_wrap = off -ij_java_assignment_wrap = off -ij_java_binary_operation_sign_on_next_line = true -ij_java_binary_operation_wrap = normal -ij_java_blank_lines_after_anonymous_class_header = 0 -ij_java_blank_lines_after_class_header = 1 -ij_java_blank_lines_after_imports = 1 -ij_java_blank_lines_after_package = 1 -ij_java_blank_lines_around_class = 1 -ij_java_blank_lines_around_field = 0 -ij_java_blank_lines_around_field_in_interface = 0 -ij_java_blank_lines_around_initializer = 1 -ij_java_blank_lines_around_method = 1 -ij_java_blank_lines_around_method_in_interface = 1 -ij_java_blank_lines_before_class_end = 0 -ij_java_blank_lines_before_imports = 1 -ij_java_blank_lines_before_method_body = 0 -ij_java_blank_lines_before_package = 0 -ij_java_block_brace_style = end_of_line -ij_java_block_comment_at_first_column = true -ij_java_call_parameters_new_line_after_left_paren = false -ij_java_call_parameters_right_paren_on_new_line = false -ij_java_call_parameters_wrap = normal -ij_java_case_statement_on_separate_line = true -ij_java_catch_on_new_line = false -ij_java_class_annotation_wrap = split_into_lines -ij_java_class_brace_style = end_of_line -ij_java_class_count_to_use_import_on_demand = 999 -ij_java_class_names_in_javadoc = 1 -ij_java_do_not_indent_top_level_class_members = false -ij_java_do_not_wrap_after_single_annotation = false -ij_java_do_while_brace_force = always -ij_java_doc_add_blank_line_after_description = true -ij_java_doc_add_blank_line_after_param_comments = false -ij_java_doc_add_blank_line_after_return = false -ij_java_doc_add_p_tag_on_empty_lines = true -ij_java_doc_align_exception_comments = true -ij_java_doc_align_param_comments = true -ij_java_doc_do_not_wrap_if_one_line = false -ij_java_doc_enable_formatting = true -ij_java_doc_enable_leading_asterisks = true -ij_java_doc_indent_on_continuation = false -ij_java_doc_keep_empty_lines = true -ij_java_doc_keep_empty_parameter_tag = true -ij_java_doc_keep_empty_return_tag = true -ij_java_doc_keep_empty_throws_tag = true -ij_java_doc_keep_invalid_tags = true -ij_java_doc_param_description_on_new_line = false -ij_java_doc_preserve_line_breaks = false -ij_java_doc_use_throws_not_exception_tag = true -ij_java_else_on_new_line = false -ij_java_entity_dd_suffix = EJB -ij_java_entity_eb_suffix = Bean -ij_java_entity_hi_suffix = Home -ij_java_entity_lhi_prefix = Local -ij_java_entity_lhi_suffix = Home -ij_java_entity_li_prefix = Local -ij_java_entity_pk_class = java.lang.String -ij_java_entity_vo_suffix = VO -ij_java_enum_constants_wrap = off -ij_java_extends_keyword_wrap = off -ij_java_extends_list_wrap = normal -ij_java_field_annotation_wrap = split_into_lines -ij_java_finally_on_new_line = false -ij_java_for_brace_force = always -ij_java_for_statement_new_line_after_left_paren = false -ij_java_for_statement_right_paren_on_new_line = false -ij_java_for_statement_wrap = normal -ij_java_generate_final_locals = true -ij_java_generate_final_parameters = true -ij_java_if_brace_force = always -ij_java_imports_layout = $*,|,* -ij_java_indent_case_from_switch = true -ij_java_insert_inner_class_imports = false -ij_java_insert_override_annotation = true -ij_java_keep_blank_lines_before_right_brace = 2 -ij_java_keep_blank_lines_between_package_declaration_and_header = 2 -ij_java_keep_blank_lines_in_code = 1 -ij_java_keep_blank_lines_in_declarations = 2 -ij_java_keep_control_statement_in_one_line = false -ij_java_keep_first_column_comment = true -ij_java_keep_indents_on_empty_lines = false -ij_java_keep_line_breaks = true -ij_java_keep_multiple_expressions_in_one_line = false -ij_java_keep_simple_blocks_in_one_line = false -ij_java_keep_simple_classes_in_one_line = false -ij_java_keep_simple_lambdas_in_one_line = false -ij_java_keep_simple_methods_in_one_line = false -ij_java_label_indent_absolute = false -ij_java_label_indent_size = 0 -ij_java_lambda_brace_style = end_of_line -ij_java_layout_static_imports_separately = true -ij_java_line_comment_add_space = false -ij_java_line_comment_at_first_column = true -ij_java_message_dd_suffix = EJB -ij_java_message_eb_suffix = Bean -ij_java_method_annotation_wrap = split_into_lines -ij_java_method_brace_style = end_of_line -ij_java_method_call_chain_wrap = normal -ij_java_method_parameters_new_line_after_left_paren = false -ij_java_method_parameters_right_paren_on_new_line = false -ij_java_method_parameters_wrap = normal -ij_java_modifier_list_wrap = false -ij_java_names_count_to_use_import_on_demand = 999 -ij_java_new_line_after_lparen_in_record_header = false -ij_java_parameter_annotation_wrap = off -ij_java_parentheses_expression_new_line_after_left_paren = false -ij_java_parentheses_expression_right_paren_on_new_line = false -ij_java_place_assignment_sign_on_next_line = false -ij_java_prefer_longer_names = true -ij_java_prefer_parameters_wrap = false -ij_java_record_components_wrap = normal -ij_java_repeat_synchronized = true -ij_java_replace_instanceof_and_cast = false -ij_java_replace_null_check = true -ij_java_replace_sum_lambda_with_method_ref = true -ij_java_resource_list_new_line_after_left_paren = false -ij_java_resource_list_right_paren_on_new_line = false -ij_java_resource_list_wrap = off -ij_java_rparen_on_new_line_in_record_header = false -ij_java_session_dd_suffix = EJB -ij_java_session_eb_suffix = Bean -ij_java_session_hi_suffix = Home -ij_java_session_lhi_prefix = Local -ij_java_session_lhi_suffix = Home -ij_java_session_li_prefix = Local -ij_java_session_si_suffix = Service -ij_java_space_after_closing_angle_bracket_in_type_argument = false -ij_java_space_after_colon = true -ij_java_space_after_comma = true -ij_java_space_after_comma_in_type_arguments = true -ij_java_space_after_for_semicolon = true -ij_java_space_after_quest = true -ij_java_space_after_type_cast = true -ij_java_space_before_annotation_array_initializer_left_brace = false -ij_java_space_before_annotation_parameter_list = false -ij_java_space_before_array_initializer_left_brace = false -ij_java_space_before_catch_keyword = true -ij_java_space_before_catch_left_brace = true -ij_java_space_before_catch_parentheses = true -ij_java_space_before_class_left_brace = true -ij_java_space_before_colon = true -ij_java_space_before_colon_in_foreach = true -ij_java_space_before_comma = false -ij_java_space_before_do_left_brace = true -ij_java_space_before_else_keyword = true -ij_java_space_before_else_left_brace = true -ij_java_space_before_finally_keyword = true -ij_java_space_before_finally_left_brace = true -ij_java_space_before_for_left_brace = true -ij_java_space_before_for_parentheses = true -ij_java_space_before_for_semicolon = false -ij_java_space_before_if_left_brace = true -ij_java_space_before_if_parentheses = true -ij_java_space_before_method_call_parentheses = false -ij_java_space_before_method_left_brace = true -ij_java_space_before_method_parentheses = false -ij_java_space_before_opening_angle_bracket_in_type_parameter = false -ij_java_space_before_quest = true -ij_java_space_before_switch_left_brace = true -ij_java_space_before_switch_parentheses = true -ij_java_space_before_synchronized_left_brace = true -ij_java_space_before_synchronized_parentheses = true -ij_java_space_before_try_left_brace = true -ij_java_space_before_try_parentheses = true -ij_java_space_before_type_parameter_list = false -ij_java_space_before_while_keyword = true -ij_java_space_before_while_left_brace = true -ij_java_space_before_while_parentheses = true -ij_java_space_inside_one_line_enum_braces = false -ij_java_space_within_empty_array_initializer_braces = false -ij_java_space_within_empty_method_call_parentheses = false -ij_java_space_within_empty_method_parentheses = false -ij_java_spaces_around_additive_operators = true -ij_java_spaces_around_assignment_operators = true -ij_java_spaces_around_bitwise_operators = true -ij_java_spaces_around_equality_operators = true -ij_java_spaces_around_lambda_arrow = true -ij_java_spaces_around_logical_operators = true -ij_java_spaces_around_method_ref_dbl_colon = false -ij_java_spaces_around_multiplicative_operators = true -ij_java_spaces_around_relational_operators = true -ij_java_spaces_around_shift_operators = true -ij_java_spaces_around_type_bounds_in_type_parameters = true -ij_java_spaces_around_unary_operator = false -ij_java_spaces_within_angle_brackets = false -ij_java_spaces_within_annotation_parentheses = false -ij_java_spaces_within_array_initializer_braces = false -ij_java_spaces_within_braces = false -ij_java_spaces_within_brackets = false -ij_java_spaces_within_cast_parentheses = false -ij_java_spaces_within_catch_parentheses = false -ij_java_spaces_within_for_parentheses = false -ij_java_spaces_within_if_parentheses = false -ij_java_spaces_within_method_call_parentheses = false -ij_java_spaces_within_method_parentheses = false -ij_java_spaces_within_parentheses = false -ij_java_spaces_within_record_header = false -ij_java_spaces_within_switch_parentheses = false -ij_java_spaces_within_synchronized_parentheses = false -ij_java_spaces_within_try_parentheses = false -ij_java_spaces_within_while_parentheses = false -ij_java_special_else_if_treatment = true -ij_java_subclass_name_suffix = Impl -ij_java_ternary_operation_signs_on_next_line = true -ij_java_ternary_operation_wrap = normal -ij_java_test_name_suffix = Test -ij_java_throws_keyword_wrap = normal -ij_java_throws_list_wrap = off -ij_java_use_external_annotations = false -ij_java_use_fq_class_names = false -ij_java_use_relative_indents = false -ij_java_use_single_class_imports = true -ij_java_variable_annotation_wrap = off -ij_java_visibility = public -ij_java_while_brace_force = always -ij_java_while_on_new_line = false -ij_java_wrap_comments = true -ij_java_wrap_first_method_in_call_chain = false -ij_java_wrap_long_lines = false - -[*.less] -ij_continuation_indent_size = 8 -ij_less_align_closing_brace_with_properties = false -ij_less_blank_lines_around_nested_selector = 1 -ij_less_blank_lines_between_blocks = 1 -ij_less_brace_placement = 0 -ij_less_enforce_quotes_on_format = false -ij_less_hex_color_long_format = false -ij_less_hex_color_lower_case = false -ij_less_hex_color_short_format = false -ij_less_hex_color_upper_case = false -ij_less_keep_blank_lines_in_code = 2 -ij_less_keep_indents_on_empty_lines = false -ij_less_keep_single_line_blocks = false -ij_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 -ij_less_space_after_colon = true -ij_less_space_before_opening_brace = true -ij_less_use_double_quotes = true -ij_less_value_alignment = 0 - -[*.proto] -max_line_length = 80 -ij_continuation_indent_size = 2 -ij_protobuf_keep_blank_lines_in_code = 2 -ij_protobuf_keep_indents_on_empty_lines = false -ij_protobuf_keep_line_breaks = true -ij_protobuf_space_after_comma = true -ij_protobuf_space_before_comma = false -ij_protobuf_spaces_around_assignment_operators = true -ij_protobuf_spaces_within_braces = false -ij_protobuf_spaces_within_brackets = false - -[*.rs] -indent_size = 4 -ij_rust_align_multiline_chained_methods = false -ij_rust_align_multiline_parameters = true -ij_rust_align_multiline_parameters_in_calls = true -ij_rust_align_ret_type = true -ij_rust_align_type_params = false -ij_rust_align_where_bounds = true -ij_rust_align_where_clause = false -ij_rust_allow_one_line_match = false -ij_rust_block_comment_at_first_column = false -ij_rust_indent_where_clause = true -ij_rust_keep_blank_lines_in_code = 2 -ij_rust_keep_blank_lines_in_declarations = 2 -ij_rust_keep_indents_on_empty_lines = false -ij_rust_keep_line_breaks = true -ij_rust_line_comment_add_space = true -ij_rust_line_comment_at_first_column = false -ij_rust_min_number_of_blanks_between_items = 1 -ij_rust_preserve_punctuation = false -ij_rust_spaces_around_assoc_type_binding = false - -[*.sass] -ij_sass_align_closing_brace_with_properties = false -ij_sass_blank_lines_around_nested_selector = 1 -ij_sass_blank_lines_between_blocks = 1 -ij_sass_brace_placement = 0 -ij_sass_enforce_quotes_on_format = false -ij_sass_hex_color_long_format = false -ij_sass_hex_color_lower_case = false -ij_sass_hex_color_short_format = false -ij_sass_hex_color_upper_case = false -ij_sass_keep_blank_lines_in_code = 2 -ij_sass_keep_indents_on_empty_lines = false -ij_sass_keep_single_line_blocks = false -ij_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 -ij_sass_space_after_colon = true -ij_sass_space_before_opening_brace = true -ij_sass_use_double_quotes = true -ij_sass_value_alignment = 0 - -[*.scss] -ij_scss_align_closing_brace_with_properties = false -ij_scss_blank_lines_around_nested_selector = 1 -ij_scss_blank_lines_between_blocks = 1 -ij_scss_brace_placement = 0 -ij_scss_enforce_quotes_on_format = false -ij_scss_hex_color_long_format = false -ij_scss_hex_color_lower_case = false -ij_scss_hex_color_short_format = false -ij_scss_hex_color_upper_case = false -ij_scss_keep_blank_lines_in_code = 2 -ij_scss_keep_indents_on_empty_lines = false -ij_scss_keep_single_line_blocks = false -ij_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 -ij_scss_space_after_colon = true -ij_scss_space_before_opening_brace = true -ij_scss_use_double_quotes = true -ij_scss_value_alignment = 0 - -[*.styl] -ij_continuation_indent_size = 8 -ij_stylus_align_closing_brace_with_properties = false -ij_stylus_blank_lines_around_nested_selector = 1 -ij_stylus_blank_lines_between_blocks = 1 -ij_stylus_brace_placement = 0 -ij_stylus_enforce_quotes_on_format = false -ij_stylus_hex_color_long_format = false -ij_stylus_hex_color_lower_case = false -ij_stylus_hex_color_short_format = false -ij_stylus_hex_color_upper_case = false -ij_stylus_keep_blank_lines_in_code = 2 -ij_stylus_keep_indents_on_empty_lines = false -ij_stylus_keep_single_line_blocks = false -ij_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 -ij_stylus_space_after_colon = true -ij_stylus_space_before_opening_brace = true -ij_stylus_use_double_quotes = true -ij_stylus_value_alignment = 0 - -[.editorconfig] -ij_editorconfig_align_group_field_declarations = false -ij_editorconfig_space_after_colon = false -ij_editorconfig_space_after_comma = true -ij_editorconfig_space_before_colon = false -ij_editorconfig_space_before_comma = false -ij_editorconfig_spaces_around_assignment_operators = true - -[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.qrc,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] -ij_continuation_indent_size = 2 -ij_xml_align_attributes = false -ij_xml_align_text = false -ij_xml_attribute_wrap = normal -ij_xml_block_comment_at_first_column = true -ij_xml_keep_blank_lines = 2 -ij_xml_keep_indents_on_empty_lines = false -ij_xml_keep_line_breaks = true -ij_xml_keep_line_breaks_in_text = true -ij_xml_keep_whitespaces = false -ij_xml_keep_whitespaces_around_cdata = preserve -ij_xml_keep_whitespaces_inside_cdata = false -ij_xml_line_comment_at_first_column = true -ij_xml_space_after_tag_name = false -ij_xml_space_around_equals_in_attribute = false -ij_xml_space_inside_empty_tag = false -ij_xml_text_wrap = normal -ij_xml_use_custom_settings = true - -[{*.ats,*.ts}] -ij_typescript_align_imports = false -ij_typescript_align_multiline_array_initializer_expression = false -ij_typescript_align_multiline_binary_operation = false -ij_typescript_align_multiline_chained_methods = false -ij_typescript_align_multiline_extends_list = false -ij_typescript_align_multiline_for = true -ij_typescript_align_multiline_parameters = true -ij_typescript_align_multiline_parameters_in_calls = false -ij_typescript_align_multiline_ternary_operation = false -ij_typescript_align_object_properties = 0 -ij_typescript_align_union_types = false -ij_typescript_align_var_statements = 0 -ij_typescript_array_initializer_new_line_after_left_brace = false -ij_typescript_array_initializer_right_brace_on_new_line = false -ij_typescript_array_initializer_wrap = off -ij_typescript_assignment_wrap = off -ij_typescript_binary_operation_sign_on_next_line = false -ij_typescript_binary_operation_wrap = off -ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** -ij_typescript_blank_lines_after_imports = 1 -ij_typescript_blank_lines_around_class = 1 -ij_typescript_blank_lines_around_field = 0 -ij_typescript_blank_lines_around_field_in_interface = 0 -ij_typescript_blank_lines_around_function = 1 -ij_typescript_blank_lines_around_method = 1 -ij_typescript_blank_lines_around_method_in_interface = 1 -ij_typescript_block_brace_style = end_of_line -ij_typescript_call_parameters_new_line_after_left_paren = false -ij_typescript_call_parameters_right_paren_on_new_line = false -ij_typescript_call_parameters_wrap = off -ij_typescript_catch_on_new_line = false -ij_typescript_chained_call_dot_on_new_line = true -ij_typescript_class_brace_style = end_of_line -ij_typescript_comma_on_new_line = false -ij_typescript_do_while_brace_force = never -ij_typescript_else_on_new_line = false -ij_typescript_enforce_trailing_comma = keep -ij_typescript_extends_keyword_wrap = off -ij_typescript_extends_list_wrap = off -ij_typescript_field_prefix = _ -ij_typescript_file_name_style = relaxed -ij_typescript_finally_on_new_line = false -ij_typescript_for_brace_force = never -ij_typescript_for_statement_new_line_after_left_paren = false -ij_typescript_for_statement_right_paren_on_new_line = false -ij_typescript_for_statement_wrap = off -ij_typescript_force_quote_style = false -ij_typescript_force_semicolon_style = false -ij_typescript_function_expression_brace_style = end_of_line -ij_typescript_if_brace_force = never -ij_typescript_import_merge_members = global -ij_typescript_import_prefer_absolute_path = global -ij_typescript_import_sort_members = true -ij_typescript_import_sort_module_name = false -ij_typescript_import_use_node_resolution = true -ij_typescript_imports_wrap = on_every_item -ij_typescript_indent_case_from_switch = true -ij_typescript_indent_chained_calls = false -ij_typescript_indent_package_children = 0 -ij_typescript_jsdoc_include_types = false -ij_typescript_jsx_attribute_value = braces -ij_typescript_keep_blank_lines_in_code = 2 -ij_typescript_keep_first_column_comment = true -ij_typescript_keep_indents_on_empty_lines = false -ij_typescript_keep_line_breaks = true -ij_typescript_keep_simple_blocks_in_one_line = false -ij_typescript_keep_simple_methods_in_one_line = false -ij_typescript_line_comment_add_space = true -ij_typescript_line_comment_at_first_column = false -ij_typescript_method_brace_style = end_of_line -ij_typescript_method_call_chain_wrap = off -ij_typescript_method_parameters_new_line_after_left_paren = false -ij_typescript_method_parameters_right_paren_on_new_line = false -ij_typescript_method_parameters_wrap = off -ij_typescript_object_literal_wrap = on_every_item -ij_typescript_parentheses_expression_new_line_after_left_paren = false -ij_typescript_parentheses_expression_right_paren_on_new_line = false -ij_typescript_place_assignment_sign_on_next_line = false -ij_typescript_prefer_as_type_cast = false -ij_typescript_prefer_explicit_types_function_expression_returns = false -ij_typescript_prefer_explicit_types_function_returns = false -ij_typescript_prefer_explicit_types_vars_fields = false -ij_typescript_prefer_parameters_wrap = false -ij_typescript_reformat_c_style_comments = false -ij_typescript_space_after_colon = true -ij_typescript_space_after_comma = true -ij_typescript_space_after_dots_in_rest_parameter = false -ij_typescript_space_after_generator_mult = true -ij_typescript_space_after_property_colon = true -ij_typescript_space_after_quest = true -ij_typescript_space_after_type_colon = true -ij_typescript_space_after_unary_not = false -ij_typescript_space_before_async_arrow_lparen = true -ij_typescript_space_before_catch_keyword = true -ij_typescript_space_before_catch_left_brace = true -ij_typescript_space_before_catch_parentheses = true -ij_typescript_space_before_class_lbrace = true -ij_typescript_space_before_class_left_brace = true -ij_typescript_space_before_colon = true -ij_typescript_space_before_comma = false -ij_typescript_space_before_do_left_brace = true -ij_typescript_space_before_else_keyword = true -ij_typescript_space_before_else_left_brace = true -ij_typescript_space_before_finally_keyword = true -ij_typescript_space_before_finally_left_brace = true -ij_typescript_space_before_for_left_brace = true -ij_typescript_space_before_for_parentheses = true -ij_typescript_space_before_for_semicolon = false -ij_typescript_space_before_function_left_parenth = true -ij_typescript_space_before_generator_mult = false -ij_typescript_space_before_if_left_brace = true -ij_typescript_space_before_if_parentheses = true -ij_typescript_space_before_method_call_parentheses = false -ij_typescript_space_before_method_left_brace = true -ij_typescript_space_before_method_parentheses = false -ij_typescript_space_before_property_colon = false -ij_typescript_space_before_quest = true -ij_typescript_space_before_switch_left_brace = true -ij_typescript_space_before_switch_parentheses = true -ij_typescript_space_before_try_left_brace = true -ij_typescript_space_before_type_colon = false -ij_typescript_space_before_unary_not = false -ij_typescript_space_before_while_keyword = true -ij_typescript_space_before_while_left_brace = true -ij_typescript_space_before_while_parentheses = true -ij_typescript_spaces_around_additive_operators = true -ij_typescript_spaces_around_arrow_function_operator = true -ij_typescript_spaces_around_assignment_operators = true -ij_typescript_spaces_around_bitwise_operators = true -ij_typescript_spaces_around_equality_operators = true -ij_typescript_spaces_around_logical_operators = true -ij_typescript_spaces_around_multiplicative_operators = true -ij_typescript_spaces_around_relational_operators = true -ij_typescript_spaces_around_shift_operators = true -ij_typescript_spaces_around_unary_operator = false -ij_typescript_spaces_within_array_initializer_brackets = false -ij_typescript_spaces_within_brackets = false -ij_typescript_spaces_within_catch_parentheses = false -ij_typescript_spaces_within_for_parentheses = false -ij_typescript_spaces_within_if_parentheses = false -ij_typescript_spaces_within_imports = false -ij_typescript_spaces_within_interpolation_expressions = false -ij_typescript_spaces_within_method_call_parentheses = false -ij_typescript_spaces_within_method_parentheses = false -ij_typescript_spaces_within_object_literal_braces = false -ij_typescript_spaces_within_object_type_braces = true -ij_typescript_spaces_within_parentheses = false -ij_typescript_spaces_within_switch_parentheses = false -ij_typescript_spaces_within_type_assertion = false -ij_typescript_spaces_within_union_types = true -ij_typescript_spaces_within_while_parentheses = false -ij_typescript_special_else_if_treatment = true -ij_typescript_ternary_operation_signs_on_next_line = false -ij_typescript_ternary_operation_wrap = off -ij_typescript_union_types_wrap = on_every_item -ij_typescript_use_chained_calls_group_indents = false -ij_typescript_use_double_quotes = true -ij_typescript_use_explicit_js_extension = global -ij_typescript_use_path_mapping = always -ij_typescript_use_public_modifier = false -ij_typescript_use_semicolon_after_statement = true -ij_typescript_var_declaration_wrap = normal -ij_typescript_while_brace_force = never -ij_typescript_while_on_new_line = false -ij_typescript_wrap_comments = false - -[{*.bash,*.sh,*.zsh}] -ij_shell_binary_ops_start_line = false -ij_shell_keep_column_alignment_padding = false -ij_shell_minify_program = false -ij_shell_redirect_followed_by_space = false -ij_shell_switch_cases_indented = false - -[{*.cjs,*.js}] -max_line_length = 80 -ij_javascript_align_imports = false -ij_javascript_align_multiline_array_initializer_expression = false -ij_javascript_align_multiline_binary_operation = false -ij_javascript_align_multiline_chained_methods = false -ij_javascript_align_multiline_extends_list = false -ij_javascript_align_multiline_for = false -ij_javascript_align_multiline_parameters = false -ij_javascript_align_multiline_parameters_in_calls = false -ij_javascript_align_multiline_ternary_operation = false -ij_javascript_align_object_properties = 0 -ij_javascript_align_union_types = false -ij_javascript_align_var_statements = 0 -ij_javascript_array_initializer_new_line_after_left_brace = false -ij_javascript_array_initializer_right_brace_on_new_line = false -ij_javascript_array_initializer_wrap = normal -ij_javascript_assignment_wrap = off -ij_javascript_binary_operation_sign_on_next_line = true -ij_javascript_binary_operation_wrap = normal -ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** -ij_javascript_blank_lines_after_imports = 1 -ij_javascript_blank_lines_around_class = 1 -ij_javascript_blank_lines_around_field = 0 -ij_javascript_blank_lines_around_function = 1 -ij_javascript_blank_lines_around_method = 1 -ij_javascript_block_brace_style = end_of_line -ij_javascript_call_parameters_new_line_after_left_paren = false -ij_javascript_call_parameters_right_paren_on_new_line = false -ij_javascript_call_parameters_wrap = normal -ij_javascript_catch_on_new_line = false -ij_javascript_chained_call_dot_on_new_line = true -ij_javascript_class_brace_style = end_of_line -ij_javascript_comma_on_new_line = false -ij_javascript_do_while_brace_force = always -ij_javascript_else_on_new_line = false -ij_javascript_enforce_trailing_comma = keep -ij_javascript_extends_keyword_wrap = off -ij_javascript_extends_list_wrap = off -ij_javascript_field_prefix = _ -ij_javascript_file_name_style = relaxed -ij_javascript_finally_on_new_line = false -ij_javascript_for_brace_force = always -ij_javascript_for_statement_new_line_after_left_paren = false -ij_javascript_for_statement_right_paren_on_new_line = false -ij_javascript_for_statement_wrap = normal -ij_javascript_force_quote_style = false -ij_javascript_force_semicolon_style = false -ij_javascript_function_expression_brace_style = end_of_line -ij_javascript_if_brace_force = always -ij_javascript_import_merge_members = global -ij_javascript_import_prefer_absolute_path = global -ij_javascript_import_sort_members = true -ij_javascript_import_sort_module_name = false -ij_javascript_import_use_node_resolution = true -ij_javascript_imports_wrap = on_every_item -ij_javascript_indent_case_from_switch = true -ij_javascript_indent_chained_calls = false -ij_javascript_indent_package_children = 0 -ij_javascript_jsx_attribute_value = braces -ij_javascript_keep_blank_lines_in_code = 1 -ij_javascript_keep_first_column_comment = true -ij_javascript_keep_indents_on_empty_lines = false -ij_javascript_keep_line_breaks = true -ij_javascript_keep_simple_blocks_in_one_line = false -ij_javascript_keep_simple_methods_in_one_line = false -ij_javascript_line_comment_add_space = true -ij_javascript_line_comment_at_first_column = false -ij_javascript_method_brace_style = end_of_line -ij_javascript_method_call_chain_wrap = off -ij_javascript_method_parameters_new_line_after_left_paren = false -ij_javascript_method_parameters_right_paren_on_new_line = false -ij_javascript_method_parameters_wrap = normal -ij_javascript_object_literal_wrap = on_every_item -ij_javascript_parentheses_expression_new_line_after_left_paren = false -ij_javascript_parentheses_expression_right_paren_on_new_line = false -ij_javascript_place_assignment_sign_on_next_line = false -ij_javascript_prefer_as_type_cast = false -ij_javascript_prefer_explicit_types_function_expression_returns = false -ij_javascript_prefer_explicit_types_function_returns = false -ij_javascript_prefer_explicit_types_vars_fields = false -ij_javascript_prefer_parameters_wrap = false -ij_javascript_reformat_c_style_comments = false -ij_javascript_space_after_colon = true -ij_javascript_space_after_comma = true -ij_javascript_space_after_dots_in_rest_parameter = false -ij_javascript_space_after_generator_mult = true -ij_javascript_space_after_property_colon = true -ij_javascript_space_after_quest = true -ij_javascript_space_after_type_colon = true -ij_javascript_space_after_unary_not = false -ij_javascript_space_before_async_arrow_lparen = true -ij_javascript_space_before_catch_keyword = true -ij_javascript_space_before_catch_left_brace = true -ij_javascript_space_before_catch_parentheses = true -ij_javascript_space_before_class_lbrace = true -ij_javascript_space_before_class_left_brace = true -ij_javascript_space_before_colon = true -ij_javascript_space_before_comma = false -ij_javascript_space_before_do_left_brace = true -ij_javascript_space_before_else_keyword = true -ij_javascript_space_before_else_left_brace = true -ij_javascript_space_before_finally_keyword = true -ij_javascript_space_before_finally_left_brace = true -ij_javascript_space_before_for_left_brace = true -ij_javascript_space_before_for_parentheses = true -ij_javascript_space_before_for_semicolon = false -ij_javascript_space_before_function_left_parenth = true -ij_javascript_space_before_generator_mult = false -ij_javascript_space_before_if_left_brace = true -ij_javascript_space_before_if_parentheses = true -ij_javascript_space_before_method_call_parentheses = false -ij_javascript_space_before_method_left_brace = true -ij_javascript_space_before_method_parentheses = false -ij_javascript_space_before_property_colon = false -ij_javascript_space_before_quest = true -ij_javascript_space_before_switch_left_brace = true -ij_javascript_space_before_switch_parentheses = true -ij_javascript_space_before_try_left_brace = true -ij_javascript_space_before_type_colon = false -ij_javascript_space_before_unary_not = false -ij_javascript_space_before_while_keyword = true -ij_javascript_space_before_while_left_brace = true -ij_javascript_space_before_while_parentheses = true -ij_javascript_spaces_around_additive_operators = true -ij_javascript_spaces_around_arrow_function_operator = true -ij_javascript_spaces_around_assignment_operators = true -ij_javascript_spaces_around_bitwise_operators = true -ij_javascript_spaces_around_equality_operators = true -ij_javascript_spaces_around_logical_operators = true -ij_javascript_spaces_around_multiplicative_operators = true -ij_javascript_spaces_around_relational_operators = true -ij_javascript_spaces_around_shift_operators = true -ij_javascript_spaces_around_unary_operator = false -ij_javascript_spaces_within_array_initializer_brackets = false -ij_javascript_spaces_within_brackets = false -ij_javascript_spaces_within_catch_parentheses = false -ij_javascript_spaces_within_for_parentheses = false -ij_javascript_spaces_within_if_parentheses = false -ij_javascript_spaces_within_imports = false -ij_javascript_spaces_within_interpolation_expressions = false -ij_javascript_spaces_within_method_call_parentheses = false -ij_javascript_spaces_within_method_parentheses = false -ij_javascript_spaces_within_object_literal_braces = false -ij_javascript_spaces_within_object_type_braces = true -ij_javascript_spaces_within_parentheses = false -ij_javascript_spaces_within_switch_parentheses = false -ij_javascript_spaces_within_type_assertion = false -ij_javascript_spaces_within_union_types = true -ij_javascript_spaces_within_while_parentheses = false -ij_javascript_special_else_if_treatment = true -ij_javascript_ternary_operation_signs_on_next_line = true -ij_javascript_ternary_operation_wrap = normal -ij_javascript_union_types_wrap = on_every_item -ij_javascript_use_chained_calls_group_indents = false -ij_javascript_use_double_quotes = true -ij_javascript_use_explicit_js_extension = global -ij_javascript_use_path_mapping = always -ij_javascript_use_public_modifier = false -ij_javascript_use_semicolon_after_statement = true -ij_javascript_var_declaration_wrap = normal -ij_javascript_while_brace_force = always -ij_javascript_while_on_new_line = false -ij_javascript_wrap_comments = false - -[{*.cjsx,*.coffee}] -ij_continuation_indent_size = 2 -ij_coffeescript_align_function_body = false -ij_coffeescript_align_imports = false -ij_coffeescript_align_multiline_array_initializer_expression = true -ij_coffeescript_align_multiline_parameters = true -ij_coffeescript_align_multiline_parameters_in_calls = false -ij_coffeescript_align_object_properties = 0 -ij_coffeescript_align_union_types = false -ij_coffeescript_align_var_statements = 0 -ij_coffeescript_array_initializer_new_line_after_left_brace = false -ij_coffeescript_array_initializer_right_brace_on_new_line = false -ij_coffeescript_array_initializer_wrap = normal -ij_coffeescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** -ij_coffeescript_blank_lines_around_function = 1 -ij_coffeescript_call_parameters_new_line_after_left_paren = false -ij_coffeescript_call_parameters_right_paren_on_new_line = false -ij_coffeescript_call_parameters_wrap = normal -ij_coffeescript_chained_call_dot_on_new_line = true -ij_coffeescript_comma_on_new_line = false -ij_coffeescript_enforce_trailing_comma = keep -ij_coffeescript_field_prefix = _ -ij_coffeescript_file_name_style = relaxed -ij_coffeescript_force_quote_style = false -ij_coffeescript_force_semicolon_style = false -ij_coffeescript_function_expression_brace_style = end_of_line -ij_coffeescript_import_merge_members = global -ij_coffeescript_import_prefer_absolute_path = global -ij_coffeescript_import_sort_members = true -ij_coffeescript_import_sort_module_name = false -ij_coffeescript_import_use_node_resolution = true -ij_coffeescript_imports_wrap = on_every_item -ij_coffeescript_indent_chained_calls = true -ij_coffeescript_indent_package_children = 0 -ij_coffeescript_jsx_attribute_value = braces -ij_coffeescript_keep_blank_lines_in_code = 2 -ij_coffeescript_keep_first_column_comment = true -ij_coffeescript_keep_indents_on_empty_lines = false -ij_coffeescript_keep_line_breaks = true -ij_coffeescript_keep_simple_methods_in_one_line = false -ij_coffeescript_method_parameters_new_line_after_left_paren = false -ij_coffeescript_method_parameters_right_paren_on_new_line = false -ij_coffeescript_method_parameters_wrap = off -ij_coffeescript_object_literal_wrap = on_every_item -ij_coffeescript_prefer_as_type_cast = false -ij_coffeescript_prefer_explicit_types_function_expression_returns = false -ij_coffeescript_prefer_explicit_types_function_returns = false -ij_coffeescript_prefer_explicit_types_vars_fields = false -ij_coffeescript_reformat_c_style_comments = false -ij_coffeescript_space_after_comma = true -ij_coffeescript_space_after_dots_in_rest_parameter = false -ij_coffeescript_space_after_generator_mult = true -ij_coffeescript_space_after_property_colon = true -ij_coffeescript_space_after_type_colon = true -ij_coffeescript_space_after_unary_not = false -ij_coffeescript_space_before_async_arrow_lparen = true -ij_coffeescript_space_before_class_lbrace = true -ij_coffeescript_space_before_comma = false -ij_coffeescript_space_before_function_left_parenth = true -ij_coffeescript_space_before_generator_mult = false -ij_coffeescript_space_before_property_colon = false -ij_coffeescript_space_before_type_colon = false -ij_coffeescript_space_before_unary_not = false -ij_coffeescript_spaces_around_additive_operators = true -ij_coffeescript_spaces_around_arrow_function_operator = true -ij_coffeescript_spaces_around_assignment_operators = true -ij_coffeescript_spaces_around_bitwise_operators = true -ij_coffeescript_spaces_around_equality_operators = true -ij_coffeescript_spaces_around_logical_operators = true -ij_coffeescript_spaces_around_multiplicative_operators = true -ij_coffeescript_spaces_around_relational_operators = true -ij_coffeescript_spaces_around_shift_operators = true -ij_coffeescript_spaces_around_unary_operator = false -ij_coffeescript_spaces_within_array_initializer_braces = false -ij_coffeescript_spaces_within_array_initializer_brackets = false -ij_coffeescript_spaces_within_imports = false -ij_coffeescript_spaces_within_index_brackets = false -ij_coffeescript_spaces_within_interpolation_expressions = false -ij_coffeescript_spaces_within_method_call_parentheses = false -ij_coffeescript_spaces_within_method_parentheses = false -ij_coffeescript_spaces_within_object_braces = false -ij_coffeescript_spaces_within_object_literal_braces = false -ij_coffeescript_spaces_within_object_type_braces = true -ij_coffeescript_spaces_within_range_brackets = false -ij_coffeescript_spaces_within_type_assertion = false -ij_coffeescript_spaces_within_union_types = true -ij_coffeescript_union_types_wrap = on_every_item -ij_coffeescript_use_chained_calls_group_indents = false -ij_coffeescript_use_double_quotes = true -ij_coffeescript_use_explicit_js_extension = global -ij_coffeescript_use_path_mapping = always -ij_coffeescript_use_public_modifier = false -ij_coffeescript_use_semicolon_after_statement = false -ij_coffeescript_var_declaration_wrap = normal - -[{*.dot,*.gv}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_dot_keep_blank_lines_in_code = 2 -ij_dot_keep_indents_on_empty_lines = false -ij_dot_label_indent_absolute = false -ij_dot_label_indent_size = 0 -ij_dot_space_after_colon = true -ij_dot_space_after_for_semicolon = true -ij_dot_space_before_class_left_brace = true -ij_dot_space_before_for_semicolon = false -ij_dot_space_before_method_left_brace = true -ij_dot_spaces_around_assignment_operators = true -ij_dot_spaces_around_equality_operators = true -ij_dot_spaces_within_brackets = false -ij_dot_use_relative_indents = false - -[{*.erb,*.rhtml}] -ij_continuation_indent_size = 2 -ij_rhtml_keep_indents_on_empty_lines = false - -[{*.ft,*.vm,*.vsl}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_vtl_keep_indents_on_empty_lines = false - -[{*.gant,*.gradle,*.groovy,*.gson,*.gy}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_groovy_align_group_field_declarations = false -ij_groovy_align_multiline_array_initializer_expression = false -ij_groovy_align_multiline_assignment = false -ij_groovy_align_multiline_binary_operation = false -ij_groovy_align_multiline_chained_methods = false -ij_groovy_align_multiline_extends_list = false -ij_groovy_align_multiline_for = true -ij_groovy_align_multiline_list_or_map = true -ij_groovy_align_multiline_method_parentheses = false -ij_groovy_align_multiline_parameters = true -ij_groovy_align_multiline_parameters_in_calls = false -ij_groovy_align_multiline_resources = true -ij_groovy_align_multiline_ternary_operation = false -ij_groovy_align_multiline_throws_list = false -ij_groovy_align_named_args_in_map = true -ij_groovy_align_throws_keyword = false -ij_groovy_array_initializer_new_line_after_left_brace = false -ij_groovy_array_initializer_right_brace_on_new_line = false -ij_groovy_array_initializer_wrap = off -ij_groovy_assert_statement_wrap = off -ij_groovy_assignment_wrap = off -ij_groovy_binary_operation_wrap = off -ij_groovy_blank_lines_after_class_header = 0 -ij_groovy_blank_lines_after_imports = 1 -ij_groovy_blank_lines_after_package = 1 -ij_groovy_blank_lines_around_class = 1 -ij_groovy_blank_lines_around_field = 0 -ij_groovy_blank_lines_around_field_in_interface = 0 -ij_groovy_blank_lines_around_method = 1 -ij_groovy_blank_lines_around_method_in_interface = 1 -ij_groovy_blank_lines_before_imports = 1 -ij_groovy_blank_lines_before_method_body = 0 -ij_groovy_blank_lines_before_package = 0 -ij_groovy_block_brace_style = end_of_line -ij_groovy_block_comment_at_first_column = true -ij_groovy_call_parameters_new_line_after_left_paren = false -ij_groovy_call_parameters_right_paren_on_new_line = false -ij_groovy_call_parameters_wrap = off -ij_groovy_catch_on_new_line = false -ij_groovy_class_annotation_wrap = split_into_lines -ij_groovy_class_brace_style = end_of_line -ij_groovy_class_count_to_use_import_on_demand = 5 -ij_groovy_do_while_brace_force = never -ij_groovy_else_on_new_line = false -ij_groovy_enum_constants_wrap = off -ij_groovy_extends_keyword_wrap = off -ij_groovy_extends_list_wrap = off -ij_groovy_field_annotation_wrap = split_into_lines -ij_groovy_finally_on_new_line = false -ij_groovy_for_brace_force = never -ij_groovy_for_statement_new_line_after_left_paren = false -ij_groovy_for_statement_right_paren_on_new_line = false -ij_groovy_for_statement_wrap = off -ij_groovy_if_brace_force = never -ij_groovy_import_annotation_wrap = 2 -ij_groovy_imports_layout = *,|,javax.**,java.**,|,$* -ij_groovy_indent_case_from_switch = true -ij_groovy_indent_label_blocks = true -ij_groovy_insert_inner_class_imports = false -ij_groovy_keep_blank_lines_before_right_brace = 2 -ij_groovy_keep_blank_lines_in_code = 2 -ij_groovy_keep_blank_lines_in_declarations = 2 -ij_groovy_keep_control_statement_in_one_line = true -ij_groovy_keep_first_column_comment = true -ij_groovy_keep_indents_on_empty_lines = false -ij_groovy_keep_line_breaks = true -ij_groovy_keep_multiple_expressions_in_one_line = false -ij_groovy_keep_simple_blocks_in_one_line = false -ij_groovy_keep_simple_classes_in_one_line = true -ij_groovy_keep_simple_lambdas_in_one_line = true -ij_groovy_keep_simple_methods_in_one_line = true -ij_groovy_label_indent_absolute = false -ij_groovy_label_indent_size = 0 -ij_groovy_lambda_brace_style = end_of_line -ij_groovy_layout_static_imports_separately = true -ij_groovy_line_comment_add_space = false -ij_groovy_line_comment_at_first_column = true -ij_groovy_method_annotation_wrap = split_into_lines -ij_groovy_method_brace_style = end_of_line -ij_groovy_method_call_chain_wrap = off -ij_groovy_method_parameters_new_line_after_left_paren = false -ij_groovy_method_parameters_right_paren_on_new_line = false -ij_groovy_method_parameters_wrap = off -ij_groovy_modifier_list_wrap = false -ij_groovy_names_count_to_use_import_on_demand = 3 -ij_groovy_parameter_annotation_wrap = off -ij_groovy_parentheses_expression_new_line_after_left_paren = false -ij_groovy_parentheses_expression_right_paren_on_new_line = false -ij_groovy_prefer_parameters_wrap = false -ij_groovy_resource_list_new_line_after_left_paren = false -ij_groovy_resource_list_right_paren_on_new_line = false -ij_groovy_resource_list_wrap = off -ij_groovy_space_after_assert_separator = true -ij_groovy_space_after_colon = true -ij_groovy_space_after_comma = true -ij_groovy_space_after_comma_in_type_arguments = true -ij_groovy_space_after_for_semicolon = true -ij_groovy_space_after_quest = true -ij_groovy_space_after_type_cast = true -ij_groovy_space_before_annotation_parameter_list = false -ij_groovy_space_before_array_initializer_left_brace = false -ij_groovy_space_before_assert_separator = false -ij_groovy_space_before_catch_keyword = true -ij_groovy_space_before_catch_left_brace = true -ij_groovy_space_before_catch_parentheses = true -ij_groovy_space_before_class_left_brace = true -ij_groovy_space_before_closure_left_brace = true -ij_groovy_space_before_colon = true -ij_groovy_space_before_comma = false -ij_groovy_space_before_do_left_brace = true -ij_groovy_space_before_else_keyword = true -ij_groovy_space_before_else_left_brace = true -ij_groovy_space_before_finally_keyword = true -ij_groovy_space_before_finally_left_brace = true -ij_groovy_space_before_for_left_brace = true -ij_groovy_space_before_for_parentheses = true -ij_groovy_space_before_for_semicolon = false -ij_groovy_space_before_if_left_brace = true -ij_groovy_space_before_if_parentheses = true -ij_groovy_space_before_method_call_parentheses = false -ij_groovy_space_before_method_left_brace = true -ij_groovy_space_before_method_parentheses = false -ij_groovy_space_before_quest = true -ij_groovy_space_before_switch_left_brace = true -ij_groovy_space_before_switch_parentheses = true -ij_groovy_space_before_synchronized_left_brace = true -ij_groovy_space_before_synchronized_parentheses = true -ij_groovy_space_before_try_left_brace = true -ij_groovy_space_before_try_parentheses = true -ij_groovy_space_before_while_keyword = true -ij_groovy_space_before_while_left_brace = true -ij_groovy_space_before_while_parentheses = true -ij_groovy_space_in_named_argument = true -ij_groovy_space_in_named_argument_before_colon = false -ij_groovy_space_within_empty_array_initializer_braces = false -ij_groovy_space_within_empty_method_call_parentheses = false -ij_groovy_spaces_around_additive_operators = true -ij_groovy_spaces_around_assignment_operators = true -ij_groovy_spaces_around_bitwise_operators = true -ij_groovy_spaces_around_equality_operators = true -ij_groovy_spaces_around_lambda_arrow = true -ij_groovy_spaces_around_logical_operators = true -ij_groovy_spaces_around_multiplicative_operators = true -ij_groovy_spaces_around_regex_operators = true -ij_groovy_spaces_around_relational_operators = true -ij_groovy_spaces_around_shift_operators = true -ij_groovy_spaces_within_annotation_parentheses = false -ij_groovy_spaces_within_array_initializer_braces = false -ij_groovy_spaces_within_braces = true -ij_groovy_spaces_within_brackets = false -ij_groovy_spaces_within_cast_parentheses = false -ij_groovy_spaces_within_catch_parentheses = false -ij_groovy_spaces_within_for_parentheses = false -ij_groovy_spaces_within_gstring_injection_braces = false -ij_groovy_spaces_within_if_parentheses = false -ij_groovy_spaces_within_list_or_map = false -ij_groovy_spaces_within_method_call_parentheses = false -ij_groovy_spaces_within_method_parentheses = false -ij_groovy_spaces_within_parentheses = false -ij_groovy_spaces_within_switch_parentheses = false -ij_groovy_spaces_within_synchronized_parentheses = false -ij_groovy_spaces_within_try_parentheses = false -ij_groovy_spaces_within_tuple_expression = false -ij_groovy_spaces_within_while_parentheses = false -ij_groovy_special_else_if_treatment = true -ij_groovy_ternary_operation_wrap = off -ij_groovy_throws_keyword_wrap = off -ij_groovy_throws_list_wrap = off -ij_groovy_use_flying_geese_braces = false -ij_groovy_use_fq_class_names = false -ij_groovy_use_fq_class_names_in_javadoc = true -ij_groovy_use_relative_indents = false -ij_groovy_use_single_class_imports = true -ij_groovy_variable_annotation_wrap = off -ij_groovy_while_brace_force = never -ij_groovy_while_on_new_line = false -ij_groovy_wrap_long_lines = false - -[{*.gemspec,*.jbuilder,*.rake,*.rb,*.rbw,*.ru,*.thor,.simplecov,capfile,cucumber,gemfile,guardfile,isolate,rails,rake,rakefile,rcov,spec,spork,vagrantfile}] -ij_ruby_align_group_field_declarations = false -ij_ruby_align_multiline_parameters = true -ij_ruby_blank_lines_around_class = 1 -ij_ruby_blank_lines_around_method = 1 -ij_ruby_chain_calls_alignment = 2 -ij_ruby_convert_brace_block_by_enter = true -ij_ruby_empty_declarations_style = 1 -ij_ruby_force_newlines_around_visibility_mods = true -ij_ruby_indent_private_methods = false -ij_ruby_indent_protected_methods = false -ij_ruby_indent_public_methods = false -ij_ruby_indent_when_cases = false -ij_ruby_keep_blank_lines_in_code = 1 -ij_ruby_keep_blank_lines_in_declarations = 1 -ij_ruby_keep_line_breaks = true -ij_ruby_parentheses_around_method_arguments = true -ij_ruby_spaces_around_assignment_operators = true -ij_ruby_spaces_around_hashrocket = true -ij_ruby_spaces_around_other_operators = true -ij_ruby_spaces_around_range_operators = false -ij_ruby_spaces_around_relational_operators = true -ij_ruby_spaces_within_array_initializer_braces = true -ij_ruby_spaces_within_braces = true - -[{*.gradle.kts,*.kt,*.kts,*.main.kts}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_kotlin_align_in_columns_case_branch = false -ij_kotlin_align_multiline_binary_operation = false -ij_kotlin_align_multiline_extends_list = false -ij_kotlin_align_multiline_method_parentheses = false -ij_kotlin_align_multiline_parameters = true -ij_kotlin_align_multiline_parameters_in_calls = false -ij_kotlin_allow_trailing_comma = false -ij_kotlin_allow_trailing_comma_on_call_site = false -ij_kotlin_assignment_wrap = off -ij_kotlin_blank_lines_after_class_header = 0 -ij_kotlin_blank_lines_around_block_when_branches = 0 -ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 -ij_kotlin_block_comment_at_first_column = true -ij_kotlin_call_parameters_new_line_after_left_paren = false -ij_kotlin_call_parameters_right_paren_on_new_line = false -ij_kotlin_call_parameters_wrap = off -ij_kotlin_catch_on_new_line = false -ij_kotlin_class_annotation_wrap = split_into_lines -ij_kotlin_continuation_indent_for_chained_calls = true -ij_kotlin_continuation_indent_for_expression_bodies = true -ij_kotlin_continuation_indent_in_argument_lists = true -ij_kotlin_continuation_indent_in_elvis = true -ij_kotlin_continuation_indent_in_if_conditions = true -ij_kotlin_continuation_indent_in_parameter_lists = true -ij_kotlin_continuation_indent_in_supertype_lists = true -ij_kotlin_else_on_new_line = false -ij_kotlin_enum_constants_wrap = off -ij_kotlin_extends_list_wrap = off -ij_kotlin_field_annotation_wrap = split_into_lines -ij_kotlin_finally_on_new_line = false -ij_kotlin_if_rparen_on_new_line = false -ij_kotlin_import_nested_classes = false -ij_kotlin_imports_layout = * -ij_kotlin_insert_whitespaces_in_simple_one_line_method = true -ij_kotlin_keep_blank_lines_before_right_brace = 2 -ij_kotlin_keep_blank_lines_in_code = 2 -ij_kotlin_keep_blank_lines_in_declarations = 2 -ij_kotlin_keep_first_column_comment = true -ij_kotlin_keep_indents_on_empty_lines = false -ij_kotlin_keep_line_breaks = true -ij_kotlin_lbrace_on_next_line = false -ij_kotlin_line_comment_add_space = false -ij_kotlin_line_comment_at_first_column = true -ij_kotlin_method_annotation_wrap = split_into_lines -ij_kotlin_method_call_chain_wrap = off -ij_kotlin_method_parameters_new_line_after_left_paren = false -ij_kotlin_method_parameters_right_paren_on_new_line = false -ij_kotlin_method_parameters_wrap = off -ij_kotlin_name_count_to_use_star_import = 999 -ij_kotlin_name_count_to_use_star_import_for_members = 999 -ij_kotlin_packages_to_use_import_on_demand = -ij_kotlin_parameter_annotation_wrap = off -ij_kotlin_space_after_comma = true -ij_kotlin_space_after_extend_colon = true -ij_kotlin_space_after_type_colon = true -ij_kotlin_space_before_catch_parentheses = true -ij_kotlin_space_before_comma = false -ij_kotlin_space_before_extend_colon = true -ij_kotlin_space_before_for_parentheses = true -ij_kotlin_space_before_if_parentheses = true -ij_kotlin_space_before_lambda_arrow = true -ij_kotlin_space_before_type_colon = false -ij_kotlin_space_before_when_parentheses = true -ij_kotlin_space_before_while_parentheses = true -ij_kotlin_spaces_around_additive_operators = true -ij_kotlin_spaces_around_assignment_operators = true -ij_kotlin_spaces_around_equality_operators = true -ij_kotlin_spaces_around_function_type_arrow = true -ij_kotlin_spaces_around_logical_operators = true -ij_kotlin_spaces_around_multiplicative_operators = true -ij_kotlin_spaces_around_range = false -ij_kotlin_spaces_around_relational_operators = true -ij_kotlin_spaces_around_unary_operator = false -ij_kotlin_spaces_around_when_arrow = true -ij_kotlin_variable_annotation_wrap = off -ij_kotlin_while_on_new_line = false -ij_kotlin_wrap_elvis_expressions = 1 -ij_kotlin_wrap_expression_body_functions = 0 -ij_kotlin_wrap_first_method_in_call_chain = false - -[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] -ij_json_keep_blank_lines_in_code = 0 -ij_json_keep_indents_on_empty_lines = false -ij_json_keep_line_breaks = true -ij_json_space_after_colon = true -ij_json_space_after_comma = true -ij_json_space_before_colon = true -ij_json_space_before_comma = false -ij_json_spaces_within_braces = false -ij_json_spaces_within_brackets = false -ij_json_wrap_long_lines = false - -[{*.hcl,*.nomad}] -ij_continuation_indent_size = 8 -ij_hcl_array_wrapping = 2 -ij_hcl_keep_blank_lines_in_code = 2 -ij_hcl_keep_indents_on_empty_lines = false -ij_hcl_keep_line_breaks = true -ij_hcl_object_wrapping = 2 -ij_hcl_property_alignment = 0 -ij_hcl_property_line_commenter_character = 0 -ij_hcl_space_after_comma = true -ij_hcl_space_before_comma = false -ij_hcl_spaces_around_assignment_operators = true -ij_hcl_spaces_within_braces = false -ij_hcl_spaces_within_brackets = false -ij_hcl_wrap_long_lines = false - -[{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}] -ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 -ij_html_align_attributes = true -ij_html_align_text = false -ij_html_attribute_wrap = normal -ij_html_block_comment_at_first_column = true -ij_html_do_not_align_children_of_min_lines = 0 -ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p -ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot -ij_html_enforce_quotes = false -ij_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 -ij_html_keep_blank_lines = 2 -ij_html_keep_indents_on_empty_lines = false -ij_html_keep_line_breaks = true -ij_html_keep_line_breaks_in_text = true -ij_html_keep_whitespaces = false -ij_html_keep_whitespaces_inside = span,pre,textarea -ij_html_line_comment_at_first_column = true -ij_html_new_line_after_last_attribute = never -ij_html_new_line_before_first_attribute = never -ij_html_quote_style = double -ij_html_remove_new_line_before_tags = br -ij_html_space_after_tag_name = false -ij_html_space_around_equality_in_attribute = false -ij_html_space_inside_empty_tag = false -ij_html_text_wrap = normal -ij_html_uniform_ident = false - -[{*.jsf,*.jsp,*.jspf,*.tag,*.tagf,*.xjsp}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_jsp_jsp_prefer_comma_separated_import_list = false -ij_jsp_keep_indents_on_empty_lines = false - -[{*.jspx,*.tagx}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_jspx_keep_indents_on_empty_lines = false - -[{*.lua,*.lua.txt}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_lua_align_consecutive_variable_declarations = false -ij_lua_align_multiline_parameters = true -ij_lua_align_multiline_parameters_in_calls = false -ij_lua_call_parameters_wrap = off -ij_lua_keep_indents_on_empty_lines = false -ij_lua_keep_simple_blocks_in_one_line = false -ij_lua_method_parameters_wrap = off -ij_lua_space_after_comma = true -ij_lua_space_before_comma = false -ij_lua_spaces_around_assignment_operators = true - -[{*.markdown,*.md}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_markdown_force_one_space_after_blockquote_symbol = true -ij_markdown_force_one_space_after_header_symbol = true -ij_markdown_force_one_space_after_list_bullet = true -ij_markdown_force_one_space_between_words = true -ij_markdown_keep_indents_on_empty_lines = false -ij_markdown_max_lines_around_block_elements = 1 -ij_markdown_max_lines_around_header = 1 -ij_markdown_max_lines_between_paragraphs = 1 -ij_markdown_min_lines_around_block_elements = 1 -ij_markdown_min_lines_around_header = 1 -ij_markdown_min_lines_between_paragraphs = 1 - -[{*.pb,*.textproto}] -ij_prototext_keep_blank_lines_in_code = 2 -ij_prototext_keep_indents_on_empty_lines = false -ij_prototext_keep_line_breaks = true -ij_prototext_space_after_colon = true -ij_prototext_space_after_comma = true -ij_prototext_space_before_colon = false -ij_prototext_space_before_comma = false -ij_prototext_spaces_within_braces = false -ij_prototext_spaces_within_brackets = false - -[{*.properties,spring.handlers,spring.schemas}] -ij_properties_align_group_field_declarations = false -ij_properties_keep_blank_lines = false -ij_properties_key_value_delimiter = equals -ij_properties_spaces_around_key_value_delimiter = false - -[{*.py,*.pyw}] -max_line_length = 80 -ij_python_align_collections_and_comprehensions = true -ij_python_align_multiline_imports = true -ij_python_align_multiline_parameters = false -ij_python_align_multiline_parameters_in_calls = true -ij_python_blank_line_at_file_end = true -ij_python_blank_lines_after_imports = 1 -ij_python_blank_lines_after_local_imports = 0 -ij_python_blank_lines_around_class = 1 -ij_python_blank_lines_around_method = 1 -ij_python_blank_lines_around_top_level_classes_functions = 2 -ij_python_blank_lines_before_first_method = 0 -ij_python_dict_alignment = 0 -ij_python_dict_new_line_after_left_brace = false -ij_python_dict_new_line_before_right_brace = false -ij_python_dict_wrapping = 1 -ij_python_from_import_new_line_after_left_parenthesis = false -ij_python_from_import_new_line_before_right_parenthesis = false -ij_python_from_import_parentheses_force_if_multiline = false -ij_python_from_import_trailing_comma_if_multiline = false -ij_python_from_import_wrapping = 1 -ij_python_hang_closing_brackets = false -ij_python_keep_blank_lines_in_code = 1 -ij_python_keep_blank_lines_in_declarations = 1 -ij_python_keep_indents_on_empty_lines = false -ij_python_keep_line_breaks = true -ij_python_new_line_after_colon = false -ij_python_new_line_after_colon_multi_clause = true -ij_python_optimize_imports_always_split_from_imports = false -ij_python_optimize_imports_case_insensitive_order = false -ij_python_optimize_imports_join_from_imports_with_same_source = false -ij_python_optimize_imports_sort_by_type_first = true -ij_python_optimize_imports_sort_imports = true -ij_python_optimize_imports_sort_names_in_from_imports = false -ij_python_space_after_comma = true -ij_python_space_after_number_sign = true -ij_python_space_after_py_colon = true -ij_python_space_before_backslash = true -ij_python_space_before_comma = false -ij_python_space_before_for_semicolon = false -ij_python_space_before_lbracket = false -ij_python_space_before_method_call_parentheses = false -ij_python_space_before_method_parentheses = false -ij_python_space_before_number_sign = true -ij_python_space_before_py_colon = false -ij_python_space_within_empty_method_call_parentheses = false -ij_python_space_within_empty_method_parentheses = false -ij_python_spaces_around_additive_operators = true -ij_python_spaces_around_assignment_operators = true -ij_python_spaces_around_bitwise_operators = true -ij_python_spaces_around_eq_in_keyword_argument = false -ij_python_spaces_around_eq_in_named_parameter = false -ij_python_spaces_around_equality_operators = true -ij_python_spaces_around_multiplicative_operators = true -ij_python_spaces_around_power_operator = true -ij_python_spaces_around_relational_operators = true -ij_python_spaces_around_shift_operators = true -ij_python_spaces_within_braces = false -ij_python_spaces_within_brackets = false -ij_python_spaces_within_method_call_parentheses = false -ij_python_spaces_within_method_parentheses = false -ij_python_use_continuation_indent_for_arguments = true -ij_python_use_continuation_indent_for_collection_and_comprehensions = false -ij_python_wrap_long_lines = false - -[{*.tf,*.tfvars}] -ij_continuation_indent_size = 8 -ij_hcl-terraform_array_wrapping = 2 -ij_hcl-terraform_keep_blank_lines_in_code = 2 -ij_hcl-terraform_keep_indents_on_empty_lines = false -ij_hcl-terraform_keep_line_breaks = true -ij_hcl-terraform_object_wrapping = 2 -ij_hcl-terraform_property_alignment = 0 -ij_hcl-terraform_property_line_commenter_character = 0 -ij_hcl-terraform_space_after_comma = true -ij_hcl-terraform_space_before_comma = false -ij_hcl-terraform_spaces_around_assignment_operators = true -ij_hcl-terraform_spaces_within_braces = false -ij_hcl-terraform_spaces_within_brackets = false -ij_hcl-terraform_wrap_long_lines = false - -[{*.toml,Cargo.lock,Gopkg.lock,Pipfile}] -indent_size = 4 -ij_continuation_indent_size = 8 -ij_toml_keep_indents_on_empty_lines = false - -[{*.yaml,*.yml}] -ij_yaml_align_values_properties = do_not_align -ij_yaml_autoinsert_sequence_marker = true -ij_yaml_block_mapping_on_new_line = false -ij_yaml_indent_sequence_value = true -ij_yaml_keep_indents_on_empty_lines = false -ij_yaml_keep_line_breaks = true -ij_yaml_sequence_on_new_line = false -ij_yaml_space_before_colon = false -ij_yaml_spaces_within_braces = true -ij_yaml_spaces_within_brackets = true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 21b3ec580..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2021 Signal Messenger, LLC -# SPDX-License-Identifier: AGPL-3.0-only - -custom: https://signal.org/donate/ diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index e69de29bb..000000000 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 041042c4b..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Service CI - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - container: ubuntu:22.04 - - steps: - - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 - - name: Set up JDK 17 - uses: actions/setup-java@de1bb2b0c5634f0fc4438d7aa9944e68f9bf86cc # v3.6.0 - with: - distribution: 'temurin' - java-version: 17 - cache: 'maven' - env: - # work around an issue with actions/runner setting an incorrect HOME in containers, which breaks maven caching - # https://github.com/actions/setup-java/issues/356 - HOME: /root - - name: Build with Maven - run: ./mvnw -e -B verify diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 6e2d65f25..000000000 --- a/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -target -local.properties -.idea -*.iml -run.sh -*~ -local.yml -config/production.yml -config/federated.yml -config/staging.yml -config/testing.yml -config/deploy.properties -/service/config/production.yml -/service/config/federated.yml -/service/config/staging.yml -/service/config/testing.yml -/service/config/deploy.properties -/service/dependency-reduced-pom.xml -.java-version -.opsmanage -put.sh -deployer-staging.properties -deployer-production.properties -deployer.log -/service/src/main/resources/org/signal/badges/Badges_*.properties -!/service/src/main/resources/org/signal/badges/Badges_en.properties -/service/src/main/resources/org/signal/subscriptions/Subscriptions_*.properties -!/service/src/main/resources/org/signal/subscriptions/Subscriptions_en.properties -.project -.classpath -.settings diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 001617ad9..000000000 --- a/.gitmodules +++ /dev/null @@ -1,11 +0,0 @@ -# Note that the implementation of the spam filter is private; internal -# developers will need to override this URL with: -# -# ``` -# git config submodule.spam-filter.url PRIVATE_URL -# ``` -# -# External developers may safely ignore this submodule. -[submodule "spam-filter"] - path = spam-filter - url = REDACTED diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml deleted file mode 100644 index a10fab5e5..000000000 --- a/.mvn/extensions.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - fr.brouillard.oss - jgitver-maven-plugin - 1.7.1 - - diff --git a/.mvn/jgitver.config.xml b/.mvn/jgitver.config.xml deleted file mode 100644 index 200d9894e..000000000 --- a/.mvn/jgitver.config.xml +++ /dev/null @@ -1,14 +0,0 @@ - - true - false - - - (.*) - - IGNORE - - - - diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index c1dd12f17..000000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index dc3affce3..000000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar diff --git a/README.md b/README.md deleted file mode 100644 index bcd5c6410..000000000 --- a/README.md +++ /dev/null @@ -1,26 +0,0 @@ -Signal-Server -================= - -Documentation -------------- - -Looking for protocol documentation? Check out the website! - -https://signal.org/docs/ - -Cryptography Notice ------------- - -This 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. -BEFORE 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. -See for more information. - -The 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. -The 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. - -License ---------------------- - -Copyright 2013-2022 Signal Messenger, LLC - -Licensed under the AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html diff --git a/event-logger/pom.xml b/event-logger/pom.xml deleted file mode 100644 index d14fbdaee..000000000 --- a/event-logger/pom.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - TextSecureServer - org.whispersystems.textsecure - JGITVER - - - 4.0.0 - event-logger - - - - com.google.cloud - google-cloud-logging - - - org.jetbrains.kotlin - kotlin-stdlib - - - org.jetbrains - - annotations - - - - - org.jetbrains.kotlinx - kotlinx-serialization-json - ${kotlinx-serialization.version} - - - - - ${project.basedir}/src/main/kotlin - ${project.basedir}/src/test/kotlin - - - - org.jetbrains.kotlin - kotlin-maven-plugin - ${kotlin.version} - - - - compile - - compile - - - - - test-compile - - test-compile - - - - - - kotlinx-serialization - - - - - org.jetbrains.kotlin - kotlin-maven-serialization - ${kotlin.version} - - - - - - - diff --git a/event-logger/src/main/kotlin/events.kt b/event-logger/src/main/kotlin/events.kt deleted file mode 100644 index 8a68eef17..000000000 --- a/event-logger/src/main/kotlin/events.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.event - -import java.util.Collections -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic -import kotlinx.serialization.modules.subclass - -val module = SerializersModule { - polymorphic(Event::class) { - subclass(RemoteConfigSetEvent::class) - subclass(RemoteConfigDeleteEvent::class) - } -} -val jsonFormat = Json { serializersModule = module } - -sealed interface Event - -@Serializable -data class RemoteConfigSetEvent( - val token: String, - val name: String, - val percentage: Int, - val defaultValue: String? = null, - val value: String? = null, - val hashKey: String? = null, - val uuids: Collection = Collections.emptyList(), -) : Event - -@Serializable -data class RemoteConfigDeleteEvent( - val token: String, - val name: String, -) : Event diff --git a/event-logger/src/main/kotlin/loggers.kt b/event-logger/src/main/kotlin/loggers.kt deleted file mode 100644 index 4e87f7ea8..000000000 --- a/event-logger/src/main/kotlin/loggers.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.event - -import com.google.cloud.logging.LogEntry -import com.google.cloud.logging.Logging -import com.google.cloud.logging.MonitoredResourceUtil -import com.google.cloud.logging.Payload.JsonPayload -import com.google.cloud.logging.Severity -import com.google.protobuf.Struct -import com.google.protobuf.util.JsonFormat -import kotlinx.serialization.encodeToString - -interface AdminEventLogger { - fun logEvent(event: Event, labels: Map?) - fun logEvent(event: Event) = logEvent(event, null) -} - -class NoOpAdminEventLogger : AdminEventLogger { - override fun logEvent(event: Event, labels: Map?) {} -} - -class GoogleCloudAdminEventLogger(private val logging: Logging, private val projectId: String, private val logName: String) : AdminEventLogger { - override fun logEvent(event: Event, labels: Map?) { - val structBuilder = Struct.newBuilder() - JsonFormat.parser().merge(jsonFormat.encodeToString(event), structBuilder) - val struct = structBuilder.build() - - val logEntryBuilder = LogEntry.newBuilder(JsonPayload.of(struct)) - .setLogName(logName) - .setSeverity(Severity.NOTICE) - .setResource(MonitoredResourceUtil.getResource(projectId, "project")); - if (labels != null) { - logEntryBuilder.setLabels(labels); - } - logging.write(listOf(logEntryBuilder.build())) - } -} diff --git a/event-logger/src/test/kotlin/org/signal/event/GoogleCloudAdminEventLoggerTest.kt b/event-logger/src/test/kotlin/org/signal/event/GoogleCloudAdminEventLoggerTest.kt deleted file mode 100644 index 2a4569efa..000000000 --- a/event-logger/src/test/kotlin/org/signal/event/GoogleCloudAdminEventLoggerTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.event - -import com.google.cloud.logging.Logging -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock - -class GoogleCloudAdminEventLoggerTest { - - @Test - fun logEvent() { - val logging = mock(Logging::class.java) - val logger = GoogleCloudAdminEventLogger(logging, "my-project", "test") - - val event = RemoteConfigDeleteEvent("token", "test") - logger.logEvent(event) - } -} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 000000000..0cd71a9b4 Binary files /dev/null and b/favicon.ico differ diff --git a/index.html b/index.html new file mode 100644 index 000000000..2dd56e233 --- /dev/null +++ b/index.html @@ -0,0 +1,20 @@ + + + + + + Signal Server API + + + + + + + + + + + diff --git a/mvnw b/mvnw deleted file mode 100755 index 8a8fb2282..000000000 --- a/mvnw +++ /dev/null @@ -1,316 +0,0 @@ -#!/bin/sh -# ---------------------------------------------------------------------------- -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ---------------------------------------------------------------------------- - -# ---------------------------------------------------------------------------- -# Maven Start Up Batch script -# -# Required ENV vars: -# ------------------ -# JAVA_HOME - location of a JDK home dir -# -# Optional ENV vars -# ----------------- -# M2_HOME - location of maven2's installed home dir -# MAVEN_OPTS - parameters passed to the Java VM when running Maven -# e.g. to debug Maven itself, use -# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -# MAVEN_SKIP_RC - flag to disable loading of mavenrc files -# ---------------------------------------------------------------------------- - -if [ -z "$MAVEN_SKIP_RC" ] ; then - - if [ -f /usr/local/etc/mavenrc ] ; then - . /usr/local/etc/mavenrc - fi - - if [ -f /etc/mavenrc ] ; then - . /etc/mavenrc - fi - - if [ -f "$HOME/.mavenrc" ] ; then - . "$HOME/.mavenrc" - fi - -fi - -# OS specific support. $var _must_ be set to either true or false. -cygwin=false; -darwin=false; -mingw=false -case "`uname`" in - CYGWIN*) cygwin=true ;; - MINGW*) mingw=true;; - Darwin*) darwin=true - # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home - # See https://developer.apple.com/library/mac/qa/qa1170/_index.html - if [ -z "$JAVA_HOME" ]; then - if [ -x "/usr/libexec/java_home" ]; then - export JAVA_HOME="`/usr/libexec/java_home`" - else - export JAVA_HOME="/Library/Java/Home" - fi - fi - ;; -esac - -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=`java-config --jre-home` - fi -fi - -if [ -z "$M2_HOME" ] ; then - ## resolve links - $0 may be a link to maven's home - PRG="$0" - - # need this for relative symlinks - while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG="`dirname "$PRG"`/$link" - fi - done - - saveddir=`pwd` - - M2_HOME=`dirname "$PRG"`/.. - - # make it fully qualified - M2_HOME=`cd "$M2_HOME" && pwd` - - cd "$saveddir" - # echo Using m2 at $M2_HOME -fi - -# For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin ; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --unix "$M2_HOME"` - [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --unix "$JAVA_HOME"` - [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --unix "$CLASSPATH"` -fi - -# For Mingw, ensure paths are in UNIX format before anything is touched -if $mingw ; then - [ -n "$M2_HOME" ] && - M2_HOME="`(cd "$M2_HOME"; pwd)`" - [ -n "$JAVA_HOME" ] && - JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" -fi - -if [ -z "$JAVA_HOME" ]; then - javaExecutable="`which javac`" - if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then - # readlink(1) is not available as standard on Solaris 10. - readLink=`which readlink` - if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then - if $darwin ; then - javaHome="`dirname \"$javaExecutable\"`" - javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" - else - javaExecutable="`readlink -f \"$javaExecutable\"`" - fi - javaHome="`dirname \"$javaExecutable\"`" - javaHome=`expr "$javaHome" : '\(.*\)/bin'` - JAVA_HOME="$javaHome" - export JAVA_HOME - fi - fi -fi - -if [ -z "$JAVACMD" ] ; then - if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - else - JAVACMD="`\\unset -f command; \\command -v java`" - fi -fi - -if [ ! -x "$JAVACMD" ] ; then - echo "Error: JAVA_HOME is not defined correctly." >&2 - echo " We cannot execute $JAVACMD" >&2 - exit 1 -fi - -if [ -z "$JAVA_HOME" ] ; then - echo "Warning: JAVA_HOME environment variable is not set." -fi - -CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher - -# traverses directory structure from process work directory to filesystem root -# first directory with .mvn subdirectory is considered project base directory -find_maven_basedir() { - - if [ -z "$1" ] - then - echo "Path not specified to find_maven_basedir" - return 1 - fi - - basedir="$1" - wdir="$1" - while [ "$wdir" != '/' ] ; do - if [ -d "$wdir"/.mvn ] ; then - basedir=$wdir - break - fi - # workaround for JBEAP-8937 (on Solaris 10/Sparc) - if [ -d "${wdir}" ]; then - wdir=`cd "$wdir/.."; pwd` - fi - # end of workaround - done - echo "${basedir}" -} - -# concatenates all lines of a file -concat_lines() { - if [ -f "$1" ]; then - echo "$(tr -s '\n' ' ' < "$1")" - fi -} - -BASE_DIR=`find_maven_basedir "$(pwd)"` -if [ -z "$BASE_DIR" ]; then - exit 1; -fi - -########################################################################################## -# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -# This allows using the maven wrapper in projects that prohibit checking in binary data. -########################################################################################## -if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found .mvn/wrapper/maven-wrapper.jar" - fi -else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." - fi - if [ -n "$MVNW_REPOURL" ]; then - jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - else - jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - fi - while IFS="=" read key value; do - case "$key" in (wrapperUrl) jarUrl="$value"; break ;; - esac - done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" - if [ "$MVNW_VERBOSE" = true ]; then - echo "Downloading from: $jarUrl" - fi - wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" - if $cygwin; then - wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` - fi - - if command -v wget > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found wget ... using wget" - fi - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - else - wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - fi - elif command -v curl > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found curl ... using curl" - fi - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl -o "$wrapperJarPath" "$jarUrl" -f - else - curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f - fi - - else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Falling back to using Java to download" - fi - javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" - # For Cygwin, switch paths to Windows format before running javac - if $cygwin; then - javaClass=`cygpath --path --windows "$javaClass"` - fi - if [ -e "$javaClass" ]; then - if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Compiling MavenWrapperDownloader.java ..." - fi - # Compiling the Java class - ("$JAVA_HOME/bin/javac" "$javaClass") - fi - if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - # Running the downloader - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Running MavenWrapperDownloader.java ..." - fi - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") - fi - fi - fi -fi -########################################################################################## -# End of extension -########################################################################################## - -export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} -if [ "$MVNW_VERBOSE" = true ]; then - echo $MAVEN_PROJECTBASEDIR -fi -MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" - -# For Cygwin, switch paths to Windows format before running java -if $cygwin; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --path --windows "$M2_HOME"` - [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` - [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --windows "$CLASSPATH"` - [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` -fi - -# Provide a "standardized" way to retrieve the CLI args that will -# work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" -export MAVEN_CMD_LINE_ARGS - -WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -exec "$JAVACMD" \ - $MAVEN_OPTS \ - $MAVEN_DEBUG_OPTS \ - -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.home=${M2_HOME}" \ - "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ - ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd deleted file mode 100644 index 23b7079a3..000000000 --- a/mvnw.cmd +++ /dev/null @@ -1,188 +0,0 @@ -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Maven Start Up Batch script -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir -@REM -@REM Optional ENV vars -@REM M2_HOME - location of maven2's installed home dir -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files -@REM ---------------------------------------------------------------------------- - -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM set title of command window -title %0 -@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* -if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -@REM ==== END VALIDATION ==== - -:init - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" -set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B -) - -@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -@REM This allows using the maven wrapper in projects that prohibit checking in binary data. -if exist %WRAPPER_JAR% ( - if "%MVNW_VERBOSE%" == "true" ( - echo Found %WRAPPER_JAR% - ) -) else ( - if not "%MVNW_REPOURL%" == "" ( - SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - ) - if "%MVNW_VERBOSE%" == "true" ( - echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %DOWNLOAD_URL% - ) - - powershell -Command "&{"^ - "$webclient = new-object System.Net.WebClient;"^ - "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ - "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ - "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ - "}" - if "%MVNW_VERBOSE%" == "true" ( - echo Finished downloading %WRAPPER_JAR% - ) -) -@REM End of extension - -@REM Provide a "standardized" way to retrieve the CLI args that will -@REM work with both Windows and non-Windows executions. -set MAVEN_CMD_LINE_ARGS=%* - -%MAVEN_JAVA_EXE% ^ - %JVM_CONFIG_MAVEN_PROPS% ^ - %MAVEN_OPTS% ^ - %MAVEN_DEBUG_OPTS% ^ - -classpath %WRAPPER_JAR% ^ - "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ - %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" -if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%"=="on" pause - -if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% - -cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml deleted file mode 100644 index afc784a65..000000000 --- a/pom.xml +++ /dev/null @@ -1,511 +0,0 @@ - - - 4.0.0 - pom - - - - central - Central Repository - https://repo.maven.apache.org/maven2 - - false - - - - dynamodb-local-oregon - DynamoDB Local Release Repository - https://s3-us-west-2.amazonaws.com/dynamodb-local/release - - false - - - - - - - ossrh-snapshots - https://oss.sonatype.org/content/repositories/snapshots - - false - - - true - - - - - - event-logger - redis-dispatch - websocket-resources - service - - - - 1.12.376 - 2.19.8 - 3.19.0 - 1.9.0 - 2.9.0 - 2.0.34 - 1.1.13 - 26.1.3 - 1.51.1 - 2.9.0 - 2.13.4 - 2.3.1 - 2.9.0 - 1.8.0 - 1.4.1 - 6.2.1.RELEASE - 8.12.54 - 7.2 - 1.10.3 - 4.11.0 - 4.1.82.Final - 1.2.0 - 3.21.7 - 0.15.2 - 1.7.0 - 3.1.0 - 1.7.30 - 21.2.0 - 0.10.4 - - UTF-8 - - - org.whispersystems.textsecure - TextSecureServer - JGITVER - - - - - com.fasterxml.jackson - jackson-bom - ${jackson.version} - pom - import - - - io.dropwizard - dropwizard-dependencies - ${dropwizard.version} - pom - import - - - - org.apache.tomcat - annotations-api - 6.0.53 - provided - - - io.netty - netty-bom - ${netty.version} - pom - import - - - com.amazonaws - aws-java-sdk-bom - ${aws.sdk.version} - pom - import - - - software.amazon.awssdk - bom - ${aws.sdk2.version} - pom - import - - - com.google.cloud - libraries-bom - ${google-cloud-libraries.version} - pom - import - - - io.github.resilience4j - resilience4j-bom - ${resilience4j.version} - pom - import - - - io.micrometer - micrometer-bom - ${micrometer.version} - pom - import - - - io.projectreactor - reactor-bom - 2020.0.24 - pom - import - - - org.jetbrains.kotlin - kotlin-bom - ${kotlin.version} - pom - import - - - com.eatthepath - pushy - ${pushy.version} - - - com.eatthepath - pushy-dropwizard-metrics-listener - ${pushy.version} - - - com.google.protobuf - protobuf-java - ${protobuf.version} - - - com.googlecode.libphonenumber - libphonenumber - ${libphonenumber.version} - - - com.vdurmont - semver4j - ${semver4j.version} - - - commons-io - commons-io - ${commons-io.version} - - - io.lettuce - lettuce-core - ${lettuce.version} - - - io.vavr - vavr - ${vavr.version} - - - javax.xml.bind - jaxb-api - ${jaxb.version} - - - net.logstash.logback - logstash-logback-encoder - ${logstash.logback.version} - - - org.apache.commons - commons-csv - ${commons-csv.version} - - - org.coursera - dropwizard-metrics-datadog - ${dropwizard-metrics-datadog.version} - - - org.glassfish.jaxb - jaxb-runtime - ${jaxb.version} - runtime - - - org.mockito - mockito-core - ${mockito.version} - test - - - org.mockito - mockito-inline - ${mockito.version} - test - - - org.opentest4j - opentest4j - ${opentest4j.version} - test - - - org.slf4j - slf4j-api - ${slf4j.version} - - - org.slf4j - slf4j-nop - ${slf4j.version} - test - - - redis.clients - jedis - ${jedis.version} - - - commons-logging - commons-logging - 1.2 - - - org.ow2.asm - asm - 9.2 - test - - - com.stripe - stripe-java - ${stripe.version} - - - com.braintreepayments.gateway - braintree-java - ${braintree.version} - - - com.google.code.gson - gson - ${gson.version} - - - org.signal - embedded-redis - 0.8.3 - test - - - org.signal - libsignal-server - 0.22.0 - - - org.apache.logging.log4j - log4j-bom - 2.17.1 - pom - import - - - - - - - org.hamcrest - hamcrest-all - 1.3 - test - - - com.github.tomakehurst - wiremock-jre8 - 2.35.0 - test - - - org.hamcrest - hamcrest-core - - - javax.xml.bind - jaxb-api - - - - - org.mockito - mockito-core - ${mockito.version} - test - - - org.assertj - assertj-core - test - - - org.junit.jupiter - junit-jupiter-api - test - - - org.junit-pioneer - junit-pioneer - 1.9.1 - test - - - - - - - include-spam-filter - - - spam-filter/pom.xml - - - - spam-filter - - - - - exclude-spam-filter - - - spam-filter/pom.xml - - - - - - - - - kr.motd.maven - os-maven-plugin - 1.7.0 - - - - - - org.xolstice.maven.plugins - protobuf-maven-plugin - 0.6.1 - - false - com.google.protobuf:protoc:3.21.1:exe:${os.detected.classifier} - grpc-java - io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} - - - - - compile - compile-custom - test-compile - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - 17 - - - - - org.apache.maven.plugins - maven-jar-plugin - 3.2.0 - - - - true - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - 3.1.2 - - - copy - test-compile - - copy-dependencies - - - test - so,dll,dylib - ${project.build.directory}/lib - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.0.0-M5 - - - - sqlite4java.library.path - ${project.build.directory}/lib - - - - - - - org.apache.maven.plugins - maven-enforcer-plugin - 3.0.0-M3 - - - - enforce - - - - - - 3.8.6 - - - - - - - - - org.apache.maven.plugins - maven-install-plugin - 3.0.0-M1 - - true - - - - - org.apache.maven.plugins - maven-deploy-plugin - 3.0.0-M1 - - true - - - - - - - diff --git a/redis-dispatch/pom.xml b/redis-dispatch/pom.xml deleted file mode 100644 index febe3a782..000000000 --- a/redis-dispatch/pom.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - TextSecureServer - org.whispersystems.textsecure - JGITVER - - 4.0.0 - redis-dispatch - - - - org.slf4j - slf4j-api - - - - diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/DispatchChannel.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/DispatchChannel.java deleted file mode 100644 index 761c59619..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/DispatchChannel.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch; - -public interface DispatchChannel { - void onDispatchMessage(String channel, byte[] message); - void onDispatchSubscribed(String channel); - void onDispatchUnsubscribed(String channel); -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/DispatchManager.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/DispatchManager.java deleted file mode 100644 index 2dbf4de2a..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/DispatchManager.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.dispatch.io.RedisPubSubConnectionFactory; -import org.whispersystems.dispatch.redis.PubSubConnection; -import org.whispersystems.dispatch.redis.PubSubReply; - -import java.io.IOException; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class DispatchManager extends Thread { - - private final Logger logger = LoggerFactory.getLogger(DispatchManager.class); - private final Executor executor = Executors.newCachedThreadPool(); - private final Map subscriptions = new ConcurrentHashMap<>(); - - private final Optional deadLetterChannel; - private final RedisPubSubConnectionFactory redisPubSubConnectionFactory; - - private PubSubConnection pubSubConnection; - private volatile boolean running; - - public DispatchManager(RedisPubSubConnectionFactory redisPubSubConnectionFactory, - Optional deadLetterChannel) - { - this.redisPubSubConnectionFactory = redisPubSubConnectionFactory; - this.deadLetterChannel = deadLetterChannel; - } - - @Override - public void start() { - this.pubSubConnection = redisPubSubConnectionFactory.connect(); - this.running = true; - super.start(); - } - - public void shutdown() { - this.running = false; - this.pubSubConnection.close(); - } - - public synchronized void subscribe(String name, DispatchChannel dispatchChannel) { - Optional previous = Optional.ofNullable(subscriptions.get(name)); - subscriptions.put(name, dispatchChannel); - - try { - pubSubConnection.subscribe(name); - } catch (IOException e) { - logger.warn("Subscription error", e); - } - - previous.ifPresent(channel -> dispatchUnsubscription(name, channel)); - } - - public synchronized void unsubscribe(String name, DispatchChannel channel) { - Optional subscription = Optional.ofNullable(subscriptions.get(name)); - - if (subscription.isPresent() && subscription.get() == channel) { - subscriptions.remove(name); - - try { - pubSubConnection.unsubscribe(name); - } catch (IOException e) { - logger.warn("Unsubscribe error", e); - } - - dispatchUnsubscription(name, subscription.get()); - } - } - - public boolean hasSubscription(String name) { - return subscriptions.containsKey(name); - } - - @Override - public void run() { - while (running) { - try { - PubSubReply reply = pubSubConnection.read(); - - switch (reply.getType()) { - case UNSUBSCRIBE: break; - case SUBSCRIBE: dispatchSubscribe(reply); break; - case MESSAGE: dispatchMessage(reply); break; - default: throw new AssertionError("Unknown pubsub reply type! " + reply.getType()); - } - } catch (IOException e) { - logger.warn("***** PubSub Connection Error *****", e); - if (running) { - this.pubSubConnection.close(); - this.pubSubConnection = redisPubSubConnectionFactory.connect(); - resubscribeAll(); - } - } - } - - logger.warn("DispatchManager Shutting Down..."); - } - - private void dispatchSubscribe(final PubSubReply reply) { - Optional subscription = Optional.ofNullable(subscriptions.get(reply.getChannel())); - - if (subscription.isPresent()) { - dispatchSubscription(reply.getChannel(), subscription.get()); - } else { - logger.info("Received subscribe event for non-existing channel: " + reply.getChannel()); - } - } - - private void dispatchMessage(PubSubReply reply) { - Optional subscription = Optional.ofNullable(subscriptions.get(reply.getChannel())); - - if (subscription.isPresent()) { - dispatchMessage(reply.getChannel(), subscription.get(), reply.getContent().get()); - } else if (deadLetterChannel.isPresent()) { - dispatchMessage(reply.getChannel(), deadLetterChannel.get(), reply.getContent().get()); - } else { - logger.warn("Received message for non-existing channel, with no dead letter handler: " + reply.getChannel()); - } - } - - private void resubscribeAll() { - new Thread(() -> { - synchronized (DispatchManager.this) { - try { - for (String name : subscriptions.keySet()) { - pubSubConnection.subscribe(name); - } - } catch (IOException e) { - logger.warn("***** RESUBSCRIPTION ERROR *****", e); - } - } - }).start(); - } - - private void dispatchMessage(final String name, final DispatchChannel channel, final byte[] message) { - executor.execute(() -> channel.onDispatchMessage(name, message)); - } - - private void dispatchSubscription(final String name, final DispatchChannel channel) { - executor.execute(() -> channel.onDispatchSubscribed(name)); - } - - private void dispatchUnsubscription(final String name, final DispatchChannel channel) { - executor.execute(() -> channel.onDispatchUnsubscribed(name)); - } -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/io/RedisInputStream.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/io/RedisInputStream.java deleted file mode 100644 index 98764c223..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/io/RedisInputStream.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.io; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; - -public class RedisInputStream { - - private static final byte CR = 0x0D; - private static final byte LF = 0x0A; - - private final InputStream inputStream; - - public RedisInputStream(InputStream inputStream) { - this.inputStream = inputStream; - } - - public String readLine() throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - boolean foundCr = false; - - while (true) { - int character = inputStream.read(); - - if (character == -1) { - throw new IOException("Stream closed!"); - } - - baos.write(character); - - if (foundCr && character == LF) break; - else if (character == CR) foundCr = true; - else if (foundCr) foundCr = false; - } - - byte[] data = baos.toByteArray(); - return new String(data, 0, data.length-2); - } - - public byte[] readFully(int size) throws IOException { - byte[] result = new byte[size]; - int offset = 0; - int remaining = result.length; - - while (remaining > 0) { - int read = inputStream.read(result, offset, remaining); - - if (read < 0) { - throw new IOException("Stream closed!"); - } - - offset += read; - remaining -= read; - } - - return result; - } - - public void close() throws IOException { - inputStream.close(); - } - -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/io/RedisPubSubConnectionFactory.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/io/RedisPubSubConnectionFactory.java deleted file mode 100644 index 898f9f6da..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/io/RedisPubSubConnectionFactory.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.io; - -import org.whispersystems.dispatch.redis.PubSubConnection; - -public interface RedisPubSubConnectionFactory { - - PubSubConnection connect(); - -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/PubSubConnection.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/PubSubConnection.java deleted file mode 100644 index 322f6e5b7..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/PubSubConnection.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.dispatch.io.RedisInputStream; -import org.whispersystems.dispatch.redis.protocol.ArrayReplyHeader; -import org.whispersystems.dispatch.redis.protocol.IntReply; -import org.whispersystems.dispatch.redis.protocol.StringReplyHeader; -import org.whispersystems.dispatch.util.Util; - -import java.io.BufferedInputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.net.Socket; -import java.util.Arrays; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicBoolean; - -public class PubSubConnection { - - private final Logger logger = LoggerFactory.getLogger(PubSubConnection.class); - - private static final byte[] UNSUBSCRIBE_TYPE = {'u', 'n', 's', 'u', 'b', 's', 'c', 'r', 'i', 'b', 'e' }; - private static final byte[] SUBSCRIBE_TYPE = {'s', 'u', 'b', 's', 'c', 'r', 'i', 'b', 'e' }; - private static final byte[] MESSAGE_TYPE = {'m', 'e', 's', 's', 'a', 'g', 'e' }; - - private static final byte[] SUBSCRIBE_COMMAND = {'S', 'U', 'B', 'S', 'C', 'R', 'I', 'B', 'E', ' ' }; - private static final byte[] UNSUBSCRIBE_COMMAND = {'U', 'N', 'S', 'U', 'B', 'S', 'C', 'R', 'I', 'B', 'E', ' '}; - private static final byte[] CRLF = {'\r', '\n' }; - - private final OutputStream outputStream; - private final RedisInputStream inputStream; - private final Socket socket; - private final AtomicBoolean closed; - - public PubSubConnection(Socket socket) throws IOException { - this.socket = socket; - this.outputStream = socket.getOutputStream(); - this.inputStream = new RedisInputStream(new BufferedInputStream(socket.getInputStream())); - this.closed = new AtomicBoolean(false); - } - - public void subscribe(String channelName) throws IOException { - if (closed.get()) throw new IOException("Connection closed!"); - - byte[] command = Util.combine(SUBSCRIBE_COMMAND, channelName.getBytes(), CRLF); - outputStream.write(command); - } - - public void unsubscribe(String channelName) throws IOException { - if (closed.get()) throw new IOException("Connection closed!"); - - byte[] command = Util.combine(UNSUBSCRIBE_COMMAND, channelName.getBytes(), CRLF); - outputStream.write(command); - } - - public PubSubReply read() throws IOException { - if (closed.get()) throw new IOException("Connection closed!"); - - ArrayReplyHeader replyHeader = new ArrayReplyHeader(inputStream.readLine()); - - if (replyHeader.getElementCount() != 3) { - throw new IOException("Received array reply header with strange count: " + replyHeader.getElementCount()); - } - - StringReplyHeader replyTypeHeader = new StringReplyHeader(inputStream.readLine()); - byte[] replyType = inputStream.readFully(replyTypeHeader.getStringLength()); - inputStream.readLine(); - - if (Arrays.equals(SUBSCRIBE_TYPE, replyType)) return readSubscribeReply(); - else if (Arrays.equals(UNSUBSCRIBE_TYPE, replyType)) return readUnsubscribeReply(); - else if (Arrays.equals(MESSAGE_TYPE, replyType)) return readMessageReply(); - else throw new IOException("Unknown reply type: " + new String(replyType)); - } - - public void close() { - try { - this.closed.set(true); - this.inputStream.close(); - this.outputStream.close(); - this.socket.close(); - } catch (IOException e) { - logger.warn("Exception while closing", e); - } - } - - private PubSubReply readMessageReply() throws IOException { - StringReplyHeader channelNameHeader = new StringReplyHeader(inputStream.readLine()); - byte[] channelName = inputStream.readFully(channelNameHeader.getStringLength()); - inputStream.readLine(); - - StringReplyHeader messageHeader = new StringReplyHeader(inputStream.readLine()); - byte[] message = inputStream.readFully(messageHeader.getStringLength()); - inputStream.readLine(); - - return new PubSubReply(PubSubReply.Type.MESSAGE, new String(channelName), Optional.of(message)); - } - - private PubSubReply readUnsubscribeReply() throws IOException { - String channelName = readSubscriptionReply(); - return new PubSubReply(PubSubReply.Type.UNSUBSCRIBE, channelName, Optional.empty()); - } - - private PubSubReply readSubscribeReply() throws IOException { - String channelName = readSubscriptionReply(); - return new PubSubReply(PubSubReply.Type.SUBSCRIBE, channelName, Optional.empty()); - } - - private String readSubscriptionReply() throws IOException { - StringReplyHeader channelNameHeader = new StringReplyHeader(inputStream.readLine()); - byte[] channelName = inputStream.readFully(channelNameHeader.getStringLength()); - inputStream.readLine(); - - new IntReply(inputStream.readLine()); - - return new String(channelName); - } - -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/PubSubReply.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/PubSubReply.java deleted file mode 100644 index 929de484c..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/PubSubReply.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis; - -import java.util.Optional; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class PubSubReply { - - public enum Type { - MESSAGE, - SUBSCRIBE, - UNSUBSCRIBE - } - - private final Type type; - private final String channel; - private final Optional content; - - public PubSubReply(Type type, String channel, Optional content) { - this.type = type; - this.channel = channel; - this.content = content; - } - - public Type getType() { - return type; - } - - public String getChannel() { - return channel; - } - - public Optional getContent() { - return content; - } - -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/ArrayReplyHeader.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/ArrayReplyHeader.java deleted file mode 100644 index 37cccb77f..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/ArrayReplyHeader.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis.protocol; - -import java.io.IOException; - -public class ArrayReplyHeader { - - private final int elementCount; - - public ArrayReplyHeader(String header) throws IOException { - if (header == null || header.length() < 2 || header.charAt(0) != '*') { - throw new IOException("Invalid array reply header: " + header); - } - - try { - this.elementCount = Integer.parseInt(header.substring(1)); - } catch (NumberFormatException e) { - throw new IOException(e); - } - } - - public int getElementCount() { - return elementCount; - } -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/IntReply.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/IntReply.java deleted file mode 100644 index d28150bbd..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/IntReply.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis.protocol; - -import java.io.IOException; - -public class IntReply { - - private final int value; - - public IntReply(String reply) throws IOException { - if (reply == null || reply.length() < 2 || reply.charAt(0) != ':') { - throw new IOException("Invalid int reply: " + reply); - } - - try { - this.value = Integer.parseInt(reply.substring(1)); - } catch (NumberFormatException e) { - throw new IOException(e); - } - } - - public int getValue() { - return value; - } -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/StringReplyHeader.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/StringReplyHeader.java deleted file mode 100644 index ece0258a6..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/redis/protocol/StringReplyHeader.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis.protocol; - -import java.io.IOException; - -public class StringReplyHeader { - - private final int stringLength; - - public StringReplyHeader(String header) throws IOException { - if (header == null || header.length() < 2 || header.charAt(0) != '$') { - throw new IOException("Invalid string reply header: " + header); - } - - try { - this.stringLength = Integer.parseInt(header.substring(1)); - } catch (NumberFormatException e) { - throw new IOException(e); - } - } - - public int getStringLength() { - return stringLength; - } -} diff --git a/redis-dispatch/src/main/java/org/whispersystems/dispatch/util/Util.java b/redis-dispatch/src/main/java/org/whispersystems/dispatch/util/Util.java deleted file mode 100644 index 21dbcc532..000000000 --- a/redis-dispatch/src/main/java/org/whispersystems/dispatch/util/Util.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.util; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; - -public class Util { - - public static byte[] combine(byte[]... elements) { - try { - int sum = 0; - - for (byte[] element : elements) { - sum += element.length; - } - - ByteArrayOutputStream baos = new ByteArrayOutputStream(sum); - - for (byte[] element : elements) { - baos.write(element); - } - - return baos.toByteArray(); - } catch (IOException e) { - throw new AssertionError(e); - } - } - - - public static void sleep(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - throw new AssertionError(e); - } - } -} diff --git a/redis-dispatch/src/test/java/org/whispersystems/dispatch/DispatchManagerTest.java b/redis-dispatch/src/test/java/org/whispersystems/dispatch/DispatchManagerTest.java deleted file mode 100644 index 4410659d6..000000000 --- a/redis-dispatch/src/test/java/org/whispersystems/dispatch/DispatchManagerTest.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.dispatch.io.RedisPubSubConnectionFactory; -import org.whispersystems.dispatch.redis.PubSubConnection; -import org.whispersystems.dispatch.redis.PubSubReply; - -public class DispatchManagerTest { - - private PubSubConnection pubSubConnection; - private RedisPubSubConnectionFactory socketFactory; - private DispatchManager dispatchManager; - private PubSubReplyInputStream pubSubReplyInputStream; - - @BeforeEach - void setUp() throws Exception { - pubSubConnection = mock(PubSubConnection.class ); - socketFactory = mock(RedisPubSubConnectionFactory.class); - pubSubReplyInputStream = new PubSubReplyInputStream(); - - when(socketFactory.connect()).thenReturn(pubSubConnection); - when(pubSubConnection.read()).thenAnswer((Answer) invocationOnMock -> pubSubReplyInputStream.read()); - - dispatchManager = new DispatchManager(socketFactory, Optional.empty()); - dispatchManager.start(); - } - - @AfterEach - void tearDown() { - dispatchManager.shutdown(); - } - - @Test - public void testConnect() { - verify(socketFactory).connect(); - } - - @Test - public void testSubscribe() { - DispatchChannel dispatchChannel = mock(DispatchChannel.class); - dispatchManager.subscribe("foo", dispatchChannel); - pubSubReplyInputStream.write(new PubSubReply(PubSubReply.Type.SUBSCRIBE, "foo", Optional.empty())); - - verify(dispatchChannel, timeout(1000)).onDispatchSubscribed(eq("foo")); - } - - @Test - public void testSubscribeUnsubscribe() { - DispatchChannel dispatchChannel = mock(DispatchChannel.class); - dispatchManager.subscribe("foo", dispatchChannel); - dispatchManager.unsubscribe("foo", dispatchChannel); - - pubSubReplyInputStream.write(new PubSubReply(PubSubReply.Type.SUBSCRIBE, "foo", Optional.empty())); - pubSubReplyInputStream.write(new PubSubReply(PubSubReply.Type.UNSUBSCRIBE, "foo", Optional.empty())); - - verify(dispatchChannel, timeout(1000)).onDispatchUnsubscribed(eq("foo")); - } - - @Test - public void testMessages() { - DispatchChannel fooChannel = mock(DispatchChannel.class); - DispatchChannel barChannel = mock(DispatchChannel.class); - - dispatchManager.subscribe("foo", fooChannel); - dispatchManager.subscribe("bar", barChannel); - - pubSubReplyInputStream.write(new PubSubReply(PubSubReply.Type.SUBSCRIBE, "foo", Optional.empty())); - pubSubReplyInputStream.write(new PubSubReply(PubSubReply.Type.SUBSCRIBE, "bar", Optional.empty())); - - verify(fooChannel, timeout(1000)).onDispatchSubscribed(eq("foo")); - verify(barChannel, timeout(1000)).onDispatchSubscribed(eq("bar")); - - pubSubReplyInputStream.write(new PubSubReply(PubSubReply.Type.MESSAGE, "foo", Optional.of("hello".getBytes()))); - pubSubReplyInputStream.write(new PubSubReply(PubSubReply.Type.MESSAGE, "bar", Optional.of("there".getBytes()))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); - verify(fooChannel, timeout(1000)).onDispatchMessage(eq("foo"), captor.capture()); - - assertArrayEquals("hello".getBytes(), captor.getValue()); - - verify(barChannel, timeout(1000)).onDispatchMessage(eq("bar"), captor.capture()); - - assertArrayEquals("there".getBytes(), captor.getValue()); - } - - private static class PubSubReplyInputStream { - - private final List pubSubReplyList = new LinkedList<>(); - - public synchronized PubSubReply read() { - try { - while (pubSubReplyList.isEmpty()) wait(); - return pubSubReplyList.remove(0); - } catch (InterruptedException e) { - throw new AssertionError(e); - } - } - - public synchronized void write(PubSubReply pubSubReply) { - pubSubReplyList.add(pubSubReply); - notifyAll(); - } - } - -} diff --git a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/PubSubConnectionTest.java b/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/PubSubConnectionTest.java deleted file mode 100644 index 4e0459359..000000000 --- a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/PubSubConnectionTest.java +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.Socket; -import java.security.SecureRandom; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.invocation.InvocationOnMock; -import org.mockito.stubbing.Answer; - -class PubSubConnectionTest { - - private static final String REPLY = "*3\r\n" + - "$9\r\n" + - "subscribe\r\n" + - "$5\r\n" + - "abcde\r\n" + - ":1\r\n" + - "*3\r\n" + - "$9\r\n" + - "subscribe\r\n" + - "$5\r\n" + - "fghij\r\n" + - ":2\r\n" + - "*3\r\n" + - "$9\r\n" + - "subscribe\r\n" + - "$5\r\n" + - "klmno\r\n" + - ":2\r\n" + - "*3\r\n" + - "$7\r\n" + - "message\r\n" + - "$5\r\n" + - "abcde\r\n" + - "$10\r\n" + - "1234567890\r\n" + - "*3\r\n" + - "$7\r\n" + - "message\r\n" + - "$5\r\n" + - "klmno\r\n" + - "$10\r\n" + - "0987654321\r\n"; - - - @Test - void testSubscribe() throws IOException { - OutputStream outputStream = mock(OutputStream.class); - Socket socket = mock(Socket.class ); - when(socket.getOutputStream()).thenReturn(outputStream); - PubSubConnection connection = new PubSubConnection(socket); - - connection.subscribe("foobar"); - - ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); - verify(outputStream).write(captor.capture()); - - assertArrayEquals(captor.getValue(), "SUBSCRIBE foobar\r\n".getBytes()); - } - - @Test - void testUnsubscribe() throws IOException { - OutputStream outputStream = mock(OutputStream.class); - Socket socket = mock(Socket.class ); - when(socket.getOutputStream()).thenReturn(outputStream); - PubSubConnection connection = new PubSubConnection(socket); - - connection.unsubscribe("bazbar"); - - ArgumentCaptor captor = ArgumentCaptor.forClass(byte[].class); - verify(outputStream).write(captor.capture()); - - assertArrayEquals(captor.getValue(), "UNSUBSCRIBE bazbar\r\n".getBytes()); - } - - @Test - void testTricklyResponse() throws Exception { - InputStream inputStream = mockInputStreamFor(new TrickleInputStream(REPLY.getBytes())); - OutputStream outputStream = mock(OutputStream.class); - Socket socket = mock(Socket.class ); - when(socket.getOutputStream()).thenReturn(outputStream); - when(socket.getInputStream()).thenReturn(inputStream); - - PubSubConnection pubSubConnection = new PubSubConnection(socket); - readResponses(pubSubConnection); - } - - @Test - void testFullResponse() throws Exception { - InputStream inputStream = mockInputStreamFor(new FullInputStream(REPLY.getBytes())); - OutputStream outputStream = mock(OutputStream.class); - Socket socket = mock(Socket.class ); - when(socket.getOutputStream()).thenReturn(outputStream); - when(socket.getInputStream()).thenReturn(inputStream); - - PubSubConnection pubSubConnection = new PubSubConnection(socket); - readResponses(pubSubConnection); - } - - @Test - void testRandomLengthResponse() throws Exception { - InputStream inputStream = mockInputStreamFor(new RandomInputStream(REPLY.getBytes())); - OutputStream outputStream = mock(OutputStream.class); - Socket socket = mock(Socket.class ); - when(socket.getOutputStream()).thenReturn(outputStream); - when(socket.getInputStream()).thenReturn(inputStream); - - PubSubConnection pubSubConnection = new PubSubConnection(socket); - readResponses(pubSubConnection); - } - - private InputStream mockInputStreamFor(final MockInputStream stub) throws IOException { - InputStream result = mock(InputStream.class); - - when(result.read()).thenAnswer(new Answer() { - @Override - public Integer answer(InvocationOnMock invocationOnMock) throws Throwable { - return stub.read(); - } - }); - - when(result.read(any(byte[].class))).thenAnswer(new Answer() { - @Override - public Integer answer(InvocationOnMock invocationOnMock) throws Throwable { - byte[] buffer = (byte[])invocationOnMock.getArguments()[0]; - return stub.read(buffer, 0, buffer.length); - } - }); - - when(result.read(any(byte[].class), anyInt(), anyInt())).thenAnswer(new Answer() { - @Override - public Integer answer(InvocationOnMock invocationOnMock) throws Throwable { - byte[] buffer = (byte[]) invocationOnMock.getArguments()[0]; - int offset = (int) invocationOnMock.getArguments()[1]; - int length = (int) invocationOnMock.getArguments()[2]; - - return stub.read(buffer, offset, length); - } - }); - - return result; - } - - private void readResponses(PubSubConnection pubSubConnection) throws Exception { - PubSubReply reply = pubSubConnection.read(); - - assertEquals(reply.getType(), PubSubReply.Type.SUBSCRIBE); - assertEquals(reply.getChannel(), "abcde"); - assertFalse(reply.getContent().isPresent()); - - reply = pubSubConnection.read(); - - assertEquals(reply.getType(), PubSubReply.Type.SUBSCRIBE); - assertEquals(reply.getChannel(), "fghij"); - assertFalse(reply.getContent().isPresent()); - - reply = pubSubConnection.read(); - - assertEquals(reply.getType(), PubSubReply.Type.SUBSCRIBE); - assertEquals(reply.getChannel(), "klmno"); - assertFalse(reply.getContent().isPresent()); - - reply = pubSubConnection.read(); - - assertEquals(reply.getType(), PubSubReply.Type.MESSAGE); - assertEquals(reply.getChannel(), "abcde"); - assertArrayEquals(reply.getContent().get(), "1234567890".getBytes()); - - reply = pubSubConnection.read(); - - assertEquals(reply.getType(), PubSubReply.Type.MESSAGE); - assertEquals(reply.getChannel(), "klmno"); - assertArrayEquals(reply.getContent().get(), "0987654321".getBytes()); - } - - private interface MockInputStream { - public int read(); - public int read(byte[] input, int offset, int length); - } - - private static class TrickleInputStream implements MockInputStream { - - private final byte[] data; - private int index = 0; - - private TrickleInputStream(byte[] data) { - this.data = data; - } - - public int read() { - return data[index++]; - } - - public int read(byte[] input, int offset, int length) { - input[offset] = data[index++]; - return 1; - } - - } - - private static class FullInputStream implements MockInputStream { - - private final byte[] data; - private int index = 0; - - private FullInputStream(byte[] data) { - this.data = data; - } - - public int read() { - return data[index++]; - } - - public int read(byte[] input, int offset, int length) { - int amount = Math.min(data.length - index, length); - System.arraycopy(data, index, input, offset, amount); - index += length; - - return amount; - } - } - - private static class RandomInputStream implements MockInputStream { - private final byte[] data; - private int index = 0; - - private RandomInputStream(byte[] data) { - this.data = data; - } - - public int read() { - return data[index++]; - } - - public int read(byte[] input, int offset, int length) { - int maxCopy = Math.min(data.length - index, length); - int randomCopy = new SecureRandom().nextInt(maxCopy) + 1; - int copyAmount = Math.min(maxCopy, randomCopy); - - System.arraycopy(data, index, input, offset, copyAmount); - index += copyAmount; - - return copyAmount; - } - - } - -} diff --git a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/ArrayReplyHeaderTest.java b/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/ArrayReplyHeaderTest.java deleted file mode 100644 index d7346019d..000000000 --- a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/ArrayReplyHeaderTest.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis.protocol; - - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import org.junit.jupiter.api.Test; - -class ArrayReplyHeaderTest { - - @Test - void testNull() { - assertThrows(IOException.class, () -> new ArrayReplyHeader(null)); - } - - @Test - void testBadPrefix() { - assertThrows(IOException.class, () -> new ArrayReplyHeader(":3")); - } - - @Test - void testEmpty() { - assertThrows(IOException.class, () -> new ArrayReplyHeader("")); - } - - @Test - void testTruncated() { - assertThrows(IOException.class, () -> new ArrayReplyHeader("*")); - } - - @Test - void testBadNumber() { - assertThrows(IOException.class, () -> new ArrayReplyHeader("*ABC")); - } - - @Test - void testValid() throws IOException { - assertEquals(4, new ArrayReplyHeader("*4").getElementCount()); - } - - - - - - - - - -} diff --git a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/IntReplyHeaderTest.java b/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/IntReplyHeaderTest.java deleted file mode 100644 index a5efb357d..000000000 --- a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/IntReplyHeaderTest.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis.protocol; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import org.junit.jupiter.api.Test; - -class IntReplyHeaderTest { - - @Test - void testNull() { - assertThrows(IOException.class, () -> new IntReply(null)); - } - - @Test - void testEmpty() { - assertThrows(IOException.class, () -> new IntReply("")); - } - - @Test - void testBadNumber() { - assertThrows(IOException.class, () -> new IntReply(":A")); - } - - @Test - void testBadFormat() { - assertThrows(IOException.class, () -> new IntReply("*")); - } - - @Test - void testValid() throws IOException { - assertEquals(23, new IntReply(":23").getValue()); - } -} diff --git a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/StringReplyHeaderTest.java b/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/StringReplyHeaderTest.java deleted file mode 100644 index 206041b6f..000000000 --- a/redis-dispatch/src/test/java/org/whispersystems/dispatch/redis/protocol/StringReplyHeaderTest.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.dispatch.redis.protocol; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.io.IOException; -import org.junit.jupiter.api.Test; - -class StringReplyHeaderTest { - - @Test - void testNull() { - assertThrows(IOException.class, () -> new StringReplyHeader(null)); - } - - @Test - void testBadNumber() { - assertThrows(IOException.class, () -> new StringReplyHeader("$100A")); - } - - @Test - void testBadPrefix() { - assertThrows(IOException.class, () -> new StringReplyHeader("*")); - } - - @Test - void testValid() throws IOException { - assertEquals(1000, new StringReplyHeader("$1000").getStringLength()); - } - -} diff --git a/service/assembly.xml b/service/assembly.xml deleted file mode 100644 index 642e2cc96..000000000 --- a/service/assembly.xml +++ /dev/null @@ -1,25 +0,0 @@ - - bin - false - - tar.gz - - - - ${project.basedir}/config - /config - - * - - - - ${project.build.directory} - / - - ${parent.artifactId}-${project.version}.jar - - - - diff --git a/service/config/sample.yml b/service/config/sample.yml deleted file mode 100644 index 920200c99..000000000 --- a/service/config/sample.yml +++ /dev/null @@ -1,407 +0,0 @@ -# Example, relatively minimal, configuration that passes validation (see `io.dropwizard.cli.CheckCommand`) -# -# `unset` values will need to be set to work properly. -# Most other values are technically valid for a local/demonstration environment, but are probably not production-ready. - -adminEventLoggingConfiguration: - credentials: | - Some credentials text - blah blah blah - projectId: some-project-id - logName: some-log-name - -stripe: - apiKey: unset - idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash - boostDescription: > - Example - supportedCurrencies: - - xts - # - ... - # - Nth supported currency - - -braintree: - merchantId: unset - publicKey: unset - privateKey: unset - environment: unset - graphqlUrl: unset - merchantAccounts: - # ISO 4217 currency code and its corresponding sub-merchant account - 'xts': unset - supportedCurrencies: - - xts - # - ... - # - Nth supported currency - -dynamoDbClientConfiguration: - region: us-west-2 # AWS Region - -dynamoDbTables: - accounts: - tableName: Example_Accounts - phoneNumberTableName: Example_Accounts_PhoneNumbers - phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers - usernamesTableName: Example_Accounts_Usernames - scanPageSize: 100 - deletedAccounts: - tableName: Example_DeletedAccounts - needsReconciliationIndexName: NeedsReconciliation - deletedAccountsLock: - tableName: Example_DeletedAccountsLock - issuedReceipts: - tableName: Example_IssuedReceipts - expiration: P30D # Duration of time until rows expire - generator: abcdefg12345678= # random base64-encoded binary sequence - keys: - tableName: Example_Keys - messages: - tableName: Example_Messages - expiration: P30D # Duration of time until rows expire - pendingAccounts: - tableName: Example_PendingAccounts - pendingDevices: - tableName: Example_PendingDevices - phoneNumberIdentifiers: - tableName: Example_PhoneNumberIdentifiers - profiles: - tableName: Example_Profiles - pushChallenge: - tableName: Example_PushChallenge - redeemedReceipts: - tableName: Example_RedeemedReceipts - expiration: P30D # Duration of time until rows expire - remoteConfig: - tableName: Example_RemoteConfig - reportMessage: - tableName: Example_ReportMessage - reservedUsernames: - tableName: Example_ReservedUsernames - subscriptions: - tableName: Example_Subscriptions - registrationRecovery: - tableName: Example_RegistrationRecovery - expiration: P300D # Duration of time until rows expire - -cacheCluster: # Redis server configuration for cache cluster - configurationUri: redis://redis.example.com:6379/ - -clientPresenceCluster: # Redis server configuration for client presence cluster - configurationUri: redis://redis.example.com:6379/ - -pubsub: # Redis server configuration for pubsub cluster - url: redis://redis.example.com:6379/ - replicaUrls: - - redis://redis.example.com:6379/ - -pushSchedulerCluster: # Redis server configuration for push scheduler cluster - configurationUri: redis://redis.example.com:6379/ - -rateLimitersCluster: # Redis server configuration for rate limiters cluster - configurationUri: redis://redis.example.com:6379/ - -directory: - client: # Configuration for interfacing with Contact Discovery Service cluster - userAuthenticationTokenSharedSecret: 00000f # hex-encoded secret shared with CDS used to generate auth tokens for Signal users - userAuthenticationTokenUserIdSecret: 00000f # hex-encoded secret shared among Signal-Servers to obscure user phone numbers from CDS - sqs: - accessKey: test # AWS SQS accessKey - accessSecret: test # AWS SQS accessSecret - queueUrls: # AWS SQS queue urls - - https://sqs.example.com/directory.fifo - server: # One or more CDS servers - - replicationName: example # CDS replication name - replicationUrl: cds.example.com # CDS replication endpoint base url - replicationPassword: example # CDS replication endpoint password - replicationCaCertificates: # CDS replication endpoint TLS certificate trust root - - | - -----BEGIN CERTIFICATE----- - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - AAAAAAAAAAAAAAAAAAAA - -----END CERTIFICATE----- - -directoryV2: - client: # Configuration for interfacing with Contact Discovery Service v2 cluster - userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users - userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users - -svr2: - userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users - userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users - -messageCache: # Redis server configuration for message store cache - persistDelayMinutes: 1 - cluster: - configurationUri: redis://redis.example.com:6379/ - -metricsCluster: - configurationUri: redis://redis.example.com:6379/ - -awsAttachments: # AWS S3 configuration - accessKey: test - accessSecret: test - bucket: aws-attachments - region: us-west-2 - -gcpAttachments: # GCP Storage configuration - domain: example.com - email: user@example.cocm - maxSizeInBytes: 1024 - pathPrefix: - rsaSigningKey: | - -----BEGIN PRIVATE KEY----- - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - AAAAAAAA - -----END PRIVATE KEY----- - -accountDatabaseCrawler: - chunkSize: 10 # accounts per run - chunkIntervalMs: 60000 # time per run - -apn: # Apple Push Notifications configuration - sandbox: true - bundleId: com.example.textsecuregcm - keyId: unset - teamId: unset - signingKey: | - -----BEGIN PRIVATE KEY----- - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - AAAAAAAA - -----END PRIVATE KEY----- - -fcm: # FCM configuration - credentials: | - { "json": true } - -cdn: - accessKey: test # AWS Access Key ID - accessSecret: test # AWS Access Secret - bucket: cdn # S3 Bucket name - region: us-west-2 # AWS region - -datadog: - apiKey: unset - environment: dev - -unidentifiedDelivery: - certificate: ABCD1234 - privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA - expiresDays: 7 - -recaptcha: - projectPath: projects/example - credentialConfigurationJson: "{ }" # service account configuration for backend authentication - -hCaptcha: - apiKey: unset - -storageService: - uri: storage.example.com - userAuthenticationTokenSharedSecret: 00000f - storageCaCertificates: - - | - -----BEGIN CERTIFICATE----- - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - AAAAAAAAAAAAAAAAAAAA - -----END CERTIFICATE----- - -backupService: - uri: backup.example.com - userAuthenticationTokenSharedSecret: 00000f - backupCaCertificates: - - | - -----BEGIN CERTIFICATE----- - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - AAAAAAAAAAAAAAAAAAAA - -----END CERTIFICATE----- - -zkConfig: - serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - 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== - -appConfig: - application: example - environment: example - configuration: example - -remoteConfig: - authorizedTokens: - - # 1st authorized token - - # 2nd authorized token - - # ... - - # Nth authorized token - globalConfig: # keys and values that are given to clients on GET /v1/config - EXAMPLE_KEY: VALUE - -paymentsService: - userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users - fixerApiKey: unset - coinMarketCapApiKey: unset - coinMarketCapCurrencyIds: - MOB: 7878 - paymentCurrencies: - # list of symbols for supported currencies - - MOB - -artService: - userAuthenticationTokenSharedSecret: 0000000f0000000f0000000f0000000f0000000f0000000f0000000f0000000f # hex-encoded 32-byte secret not shared with any external service, but used in ArtController - userAuthenticationTokenUserIdSecret: 00000f # hex-encoded secret to obscure user phone numbers from Sticker Creator - -badges: - badges: - - id: TEST - category: other - sprites: # exactly 6 - - sprite-1.png - - sprite-2.png - - sprite-3.png - - sprite-4.png - - sprite-5.png - - sprite-6.png - svg: example.svg - svgs: - - light: example-light.svg - dark: example-dark.svg - badgeIdsEnabledForAll: - - TEST - receiptLevels: - '1': TEST - -subscription: # configuration for Stripe subscriptions - badgeGracePeriod: P15D - levels: - 500: - badge: EXAMPLE - prices: - # list of ISO 4217 currency codes and amounts for the given badge level - xts: - amount: '10' - processorIds: - STRIPE: price_example # stripe Price ID - BRAINTREE: plan_example # braintree Plan ID - -oneTimeDonations: - boost: - level: 1 - expiration: P90D - badge: EXAMPLE - gift: - level: 10 - expiration: P90D - badge: EXAMPLE - currencies: - # ISO 4217 currency codes and amounts in those currencies - xts: - minimum: '0.5' - gift: '2' - boosts: - - '1' - - '2' - - '4' - - '8' - - '20' - - '40' - -registrationService: - host: registration.example.com - apiKey: EXAMPLE - registrationCaCertificate: | # Registration service TLS certificate trust root - -----BEGIN CERTIFICATE----- - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz - AAAAAAAAAAAAAAAAAAAA - -----END CERTIFICATE----- diff --git a/service/pom.xml b/service/pom.xml deleted file mode 100644 index ebc140546..000000000 --- a/service/pom.xml +++ /dev/null @@ -1,630 +0,0 @@ - - - - TextSecureServer - org.whispersystems.textsecure - JGITVER - - 4.0.0 - service - - - - jakarta.servlet - jakarta.servlet-api - - - jakarta.validation - jakarta.validation-api - - - jakarta.ws.rs - jakarta.ws.rs-api - - - - org.whispersystems.textsecure - event-logger - ${project.version} - - - org.whispersystems.textsecure - redis-dispatch - ${project.version} - - - org.whispersystems.textsecure - websocket-resources - ${project.version} - - - org.signal - libsignal-server - - - - io.dropwizard - dropwizard-core - - - io.dropwizard - dropwizard-auth - - - io.dropwizard - dropwizard-client - - - io.dropwizard - dropwizard-db - - - io.dropwizard - dropwizard-logging - - - io.dropwizard - dropwizard-metrics - - - io.dropwizard - dropwizard-util - - - io.dropwizard - dropwizard-servlets - - - io.dropwizard - dropwizard-lifecycle - - - io.dropwizard - dropwizard-jersey - - - io.dropwizard - dropwizard-jetty - - - io.dropwizard - dropwizard-validation - - - io.dropwizard - dropwizard-migrations - runtime - - - - org.slf4j - slf4j-api - - - ch.qos.logback - logback-core - - - ch.qos.logback - logback-access - - - ch.qos.logback - logback-classic - - - net.logstash.logback - logstash-logback-encoder - - - - io.dropwizard.metrics - metrics-core - - - io.dropwizard.metrics - metrics-healthchecks - - - io.dropwizard.metrics - metrics-annotation - - - org.glassfish.jersey.core - jersey-common - - - org.glassfish.jersey.core - jersey-server - - - org.glassfish.jersey.core - jersey-client - - - org.glassfish.jaxb - jaxb-runtime - - - - io.dropwizard - dropwizard-testing - test - - - junit - junit - - - - - - org.eclipse.jetty.websocket - websocket-api - - - org.eclipse.jetty - jetty-servlets - - - - org.apache.commons - commons-lang3 - - - org.apache.commons - commons-csv - - - - com.google.firebase - firebase-admin - 9.1.1 - - - - com.google.code.findbugs - jsr305 - - - - io.github.resilience4j - resilience4j-circuitbreaker - - - io.github.resilience4j - resilience4j-retry - - - io.github.resilience4j - resilience4j-reactor - - - - io.grpc - grpc-netty-shaded - runtime - - - io.grpc - grpc-protobuf - - - io.grpc - grpc-stub - - - - org.apache.tomcat - annotations-api - provided - - - - io.micrometer - micrometer-core - - - io.micrometer - micrometer-registry-datadog - - - org.coursera - dropwizard-metrics-datadog - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-annotations - - - com.fasterxml.jackson.core - jackson-databind - - - com.fasterxml.jackson.dataformat - jackson-dataformat-yaml - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - - - com.fasterxml.jackson.jaxrs - jackson-jaxrs-json-provider - - - - software.amazon.awssdk - sts - - - software.amazon.awssdk - s3 - - - software.amazon.awssdk - sqs - - - software.amazon.awssdk - dynamodb - - - software.amazon.awssdk - appconfig - - - software.amazon.awssdk - appconfigdata - - - com.amazonaws - aws-java-sdk-core - - - com.amazonaws - dynamodb-lock-client - 1.1.0 - - - commons-logging - commons-logging - - - - - - redis.clients - jedis - - - - io.lettuce - lettuce-core - - - - com.eatthepath - pushy - - - com.eatthepath - pushy-dropwizard-metrics-listener - - - - com.vdurmont - semver4j - - - - com.google.guava - guava - - - - com.google.protobuf - protobuf-java - - - - com.googlecode.libphonenumber - libphonenumber - - - - net.sourceforge.argparse4j - argparse4j - - - - org.glassfish.jersey.test-framework - jersey-test-framework-core - test - - - junit - junit - - - - - org.glassfish.jersey.test-framework.providers - jersey-test-framework-provider-grizzly2 - test - - - javax.servlet - javax.servlet-api - - - junit - junit - - - - - - com.almworks.sqlite4java - sqlite4java - 1.0.392 - test - - - - io.projectreactor - reactor-core - - - io.vavr - vavr - - - - org.junit.jupiter - junit-jupiter-params - test - - - - io.projectreactor - reactor-test - - - - org.signal - embedded-redis - test - - - - com.fasterxml.uuid - java-uuid-generator - 4.0.1 - test - - - - com.amazonaws - DynamoDBLocal - 1.21.0 - test - - - io.github.ganadist.sqlite4java - libsqlite4java-osx-aarch64 - 1.0.392 - dylib - test - - - - com.google.cloud - google-cloud-recaptchaenterprise - - - - com.stripe - stripe-java - - - - com.braintreepayments.gateway - braintree-java - - - - com.apollographql.apollo3 - apollo-api-jvm - 3.7.1 - - - - - - - exclude-spam-filter - - - - org.apache.maven.plugins - maven-shade-plugin - 3.2.4 - - true - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - - package - - shade - - - - - - org.whispersystems.textsecuregcm.WhisperServerService - - - - - - - - - org.apache.maven.plugins - maven-assembly-plugin - 3.3.0 - - - assembly.xml - - - - - make-assembly - package - - single - - - - - - - org.codehaus.mojo - properties-maven-plugin - 1.0.0 - - - read-deploy-configuration - deploy - - read-project-properties - - - ${project.basedir}/config/deploy.properties - - - - - - - org.signal - s3-upload-maven-plugin - 1.6-SNAPSHOT - - ${project.build.directory}/${project.build.finalName}-bin.tar.gz - ${deploy.bucketName} - ${deploy.bucketRegion} - ${project.build.finalName}-bin.tar.gz - - - - deploy-to-s3 - deploy - - s3-upload - - - - - - - - - - - ${project.parent.artifactId}-${project.version} - - - org.codehaus.mojo - templating-maven-plugin - 1.0.0 - - - filter-src - - filter-sources - - - - - - - org.apache.maven.plugins - maven-jar-plugin - - - - test-jar - - - - - - - org.codehaus.mojo - exec-maven-plugin - 3.0.0 - - - check-all-service-config - verify - - java - - - - - org.whispersystems.textsecuregcm.CheckServiceConfigurations - test - - ${project.basedir}/config - - - - - - com.github.aoudiamoncef - apollo-client-maven-plugin - 5.0.0 - - - - generate - - - - - - braintree - - com.braintree.graphql.client - - - - - - - - - - - diff --git a/service/src/main/graphql/braintree/ChargePayPalOneTimePayment.graphql b/service/src/main/graphql/braintree/ChargePayPalOneTimePayment.graphql deleted file mode 100644 index 2007a93be..000000000 --- a/service/src/main/graphql/braintree/ChargePayPalOneTimePayment.graphql +++ /dev/null @@ -1,9 +0,0 @@ -# https://graphql.braintreepayments.com/reference/#Mutation--chargePaymentMethod -mutation ChargePayPalOneTimePayment($input: ChargePaymentMethodInput!) { - chargePaymentMethod(input: $input) { - transaction { - id, - status - } - } -} diff --git a/service/src/main/graphql/braintree/CreatePayPalBillingAgreement.graphql b/service/src/main/graphql/braintree/CreatePayPalBillingAgreement.graphql deleted file mode 100644 index 7d1887334..000000000 --- a/service/src/main/graphql/braintree/CreatePayPalBillingAgreement.graphql +++ /dev/null @@ -1,6 +0,0 @@ -mutation CreatePayPalBillingAgreement($input: CreatePayPalBillingAgreementInput!) { - createPayPalBillingAgreement(input: $input) { - approvalUrl, - billingAgreementToken - } -} diff --git a/service/src/main/graphql/braintree/CreatePayPalOneTimePayment.graphql b/service/src/main/graphql/braintree/CreatePayPalOneTimePayment.graphql deleted file mode 100644 index 58ee6b6e9..000000000 --- a/service/src/main/graphql/braintree/CreatePayPalOneTimePayment.graphql +++ /dev/null @@ -1,7 +0,0 @@ -# https://graphql.braintreepayments.com/reference/#Mutation--createPayPalOneTimePayment -mutation CreatePayPalOneTimePayment($input: CreatePayPalOneTimePaymentInput!) { - createPayPalOneTimePayment(input: $input) { - approvalUrl, - paymentId - } -} diff --git a/service/src/main/graphql/braintree/TokenizePayPalBillingAgreement.graphql b/service/src/main/graphql/braintree/TokenizePayPalBillingAgreement.graphql deleted file mode 100644 index ca07fa375..000000000 --- a/service/src/main/graphql/braintree/TokenizePayPalBillingAgreement.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation TokenizePayPalBillingAgreement($input: TokenizePayPalBillingAgreementInput!) { - tokenizePayPalBillingAgreement(input: $input) { - paymentMethod { - id - } - } -} diff --git a/service/src/main/graphql/braintree/TokenizePayPalOneTimePayment.graphql b/service/src/main/graphql/braintree/TokenizePayPalOneTimePayment.graphql deleted file mode 100644 index ae0d5b72f..000000000 --- a/service/src/main/graphql/braintree/TokenizePayPalOneTimePayment.graphql +++ /dev/null @@ -1,8 +0,0 @@ -# https://graphql.braintreepayments.com/reference/#Mutation--tokenizePayPalOneTimePayment -mutation TokenizePayPalOneTimePayment($input: TokenizePayPalOneTimePaymentInput!) { - tokenizePayPalOneTimePayment(input: $input) { - paymentMethod { - id - } - } -} diff --git a/service/src/main/graphql/braintree/VaultPaymentMethod.graphql b/service/src/main/graphql/braintree/VaultPaymentMethod.graphql deleted file mode 100644 index bcce3f7ea..000000000 --- a/service/src/main/graphql/braintree/VaultPaymentMethod.graphql +++ /dev/null @@ -1,7 +0,0 @@ -mutation VaultPaymentMethod($input: VaultPaymentMethodInput!) { - vaultPaymentMethod(input: $input) { - paymentMethod { - id - } - } -} diff --git a/service/src/main/graphql/braintree/schema.json b/service/src/main/graphql/braintree/schema.json deleted file mode 100644 index 32e2145da..000000000 --- a/service/src/main/graphql/braintree/schema.json +++ /dev/null @@ -1,35093 +0,0 @@ -{ - "data": { - "__schema": { - "queryType": { - "name": "Query" - }, - "mutationType": { - "name": "Mutation" - }, - "subscriptionType": null, - "types": [ - { - "kind": "ENUM", - "name": "ACHStandardEntryClassCode", - "description": "A NACHA standard entry class (SEC) code, which designates how an ACH transaction was authorized.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CCD", - "description": "Corporate credit or debit.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PPD", - "description": "Prearranged payment and deposit.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TEL", - "description": "Telephone-initiated.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WEB", - "description": "Internet-initiated/mobile.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ACRType", - "description": "The authentication context class reference that indcates how a universal access token can be used.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CLIENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SERVER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AcceptDisputeInput", - "description": "Top-level input fields for accepting a dispute.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disputeId", - "description": "The ID of the dispute to be accepted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "AcceptDisputePayload", - "description": "Top-level field returned when accepting a dispute.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "dispute", - "description": "Information about the dispute that was accepted.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Dispute", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "AccessToken", - "description": "An OAuth access token.", - "fields": [ - { - "name": "accessToken", - "description": "The access token.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refreshToken", - "description": "The refresh token for getting a new access token.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenType", - "description": "The type of token.", - "args": [], - "type": { - "kind": "ENUM", - "name": "OAuthTokenType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "expiresAt", - "description": "Expiration in ISO time format.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "AccountCreationStatus", - "description": "The status of the business account creation request.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "COMPLETED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DECLINED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IN_SETUP", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IN_VETTING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUBMITTED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AccountCreationStatusSearchInput", - "description": "Input fields for searching for BusinessAccountCreationRequests by their `AccountCreationStatus`.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "The creation status is exactly this value.", - "type": { - "kind": "ENUM", - "name": "AccountCreationStatus", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "The creation status is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "AccountCreationStatus", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Address", - "description": "Representation of an address.", - "fields": [ - { - "name": "company", - "description": "Company name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "streetAddress", - "description": "The street address.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `addressLine1` instead." - }, - { - "name": "addressLine1", - "description": "The first line of the street address, such as street number, street name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "extendedAddress", - "description": "Extended address information, such as an apartment or suite number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `addressLine2` instead." - }, - { - "name": "addressLine2", - "description": "Extended address information, such as an apartment number or suite number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "firstName", - "description": "First name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `fullName` instead." - }, - { - "name": "lastName", - "description": "Last name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `fullName` instead." - }, - { - "name": "fullName", - "description": "Full name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "locality", - "description": "Locality/city.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `adminArea2` instead." - }, - { - "name": "adminArea2", - "description": "A city, town, or village.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "region", - "description": "State or province.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `adminArea1` instead." - }, - { - "name": "adminArea1", - "description": "Highest level subdivision, such as state, province, or ISO-3166-2 subdivison.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "postalCode", - "description": "Postal code, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "countryCode", - "description": "Country code for the address.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "phoneNumber", - "description": "Phone number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "description": "Input fields for an Address.", - "fields": null, - "inputFields": [ - { - "name": "company", - "description": "Company name. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "streetAddress", - "description": "The street address. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "addressLine1", - "description": "The first line of the street address, such as street number, street name. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "extendedAddress", - "description": "Extended address information, such as an apartment or suite number. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "addressLine2", - "description": "Extended address information, such as apartment number or suite number. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "firstName", - "description": "First name. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lastName", - "description": "Last name. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locality", - "description": "Locality/city. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "adminArea2", - "description": "A city, town or village. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "region", - "description": "State or province. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "adminArea1", - "description": "Highest level subdivision, such as state, province or ISO-3166-2 subdivision. 255 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "postalCode", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "Country code for the address.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCodeAlpha3", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCodeAlpha2", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCodeNumeric", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryName", - "description": "Deprecated: This field is included for supporting legacy clients. Please use `countryCode` instead.\n\nCountry name for the address.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Amount", - "description": "A monetary amount, either a whole number or a number with exactly two or three decimal places.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ApplePayConfiguration", - "description": "Configuration for Apple Pay on iOS.", - "fields": [ - { - "name": "status", - "description": "The environment being used for Apple Pay.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ApplePayStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "countryCode", - "description": "The country code of the acquiring bank where the transaction is likely to be processed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CountryCodeAlpha2", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "currencyCode", - "description": "The merchant's Apple Pay currency code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantIdentifier", - "description": "The merchant identifier that must be supplied when making an Apple Pay request.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "supportedCardBrands", - "description": "A list of card brands supported by the merchant for Apple Pay.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ApplePayOriginDetails", - "description": "Additional information about the payment method specific to Apple Pay.", - "fields": [ - { - "name": "paymentInstrumentName", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ApplePayStatus", - "description": "The environment being used for Apple Pay.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "MOCK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OFF", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRODUCTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mock", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "off", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "production", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ApplePayWebConfiguration", - "description": "Configuration for Apple Pay on web.", - "fields": [ - { - "name": "countryCode", - "description": "The merchant's Apple Pay country code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CountryCodeAlpha2", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "currencyCode", - "description": "The merchant's Apple Pay currency code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantIdentifier", - "description": "The merchant identifier that must be supplied when making an Apple Pay request.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "supportedCardBrands", - "description": "A list of card brands supported by the merchant for Apple Pay.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ApplicationBankAccountPurpose", - "description": "The purpose of the merchant application bank account.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CHECKING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SAVINGS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ApplicationStatus", - "description": "The status of a merchant account application.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "APPROVED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROCESSING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REJECTED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "AuthenticationInsight", - "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.", - "fields": [ - { - "name": "merchantAccountId", - "description": "The merchant account used to determine authentication insight.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customerAuthenticationRegulationEnvironment", - "description": "The customer authentication regulation environment that applies when transacting with this payment method and merchant account.", - "args": [], - "type": { - "kind": "ENUM", - "name": "CustomerAuthenticationRegulationEnvironment", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customerAuthenticationIndicator", - "description": "A value indicating when to perform further customer authentication.", - "args": [], - "type": { - "kind": "ENUM", - "name": "CustomerAuthenticationIndicator", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AuthenticationInsightInput", - "description": "Input fields when requesting authentication insight for a payment method.", - "fields": null, - "inputFields": [ - { - "name": "merchantAccountId", - "description": "ID of the merchant account that will be used when charging this payment method.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The intended transaction amount to be authorized on this payment method.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "recurringCustomerConsent", - "description": "A flag indicating whether the customer has consented to further recurring transactions.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "recurringMaxAmount", - "description": "The maximum amount permitted for recurring transactions set by the customer.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "AuthorizationAdjustment", - "description": "Records of authorization adjustments performed when a transaction is captured for less or more than its original authorization amount.", - "fields": [ - { - "name": "amount", - "description": "Difference between the authorized amount and the amount captured. Negative values indicate the authorized amount was adjusted down.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "successful", - "description": "Indicates if the adjustment was successful or not.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when this adjustment was performed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Processor response from this adjustment.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionAuthorizationAdjustmentProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "AuthorizationExpiredEvent", - "description": "Accompanying information for an authorization expired transaction.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the authorization for this transaction was marked expired.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AuthorizeCreditCardInput", - "description": "Top-level input fields for creating a transaction by authorizing a credit card.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of a credit card payment method to be authorized.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields related to the credit card being authorized.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CreditCardTransactionOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the authorization, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AuthorizePayPalAccountInput", - "description": "Top-level input fields for creating a transaction by authorizing a PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of a PayPal payment method to be authorized.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields related to the PayPal account being authorized.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AuthorizePayPalAccountOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the authorization, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AuthorizePayPalAccountOptionsInput", - "description": "Input fields for authorizing a PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "customField", - "description": "Variable passed directly to PayPal for your own tracking purposes. Customers do not see this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "description", - "description": "Description of the transaction that is displayed to customers in PayPal email receipts.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "payee", - "description": "Deprecated: This field is no longer supported.", - "type": { - "kind": "INPUT_OBJECT", - "name": "PayPalPayeeOptionsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AuthorizePaymentMethodInput", - "description": "Top-level input fields for creating a transaction by authorizing a payment method.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of a payment method to be authorized.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the authorization, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AuthorizeVenmoAccountInput", - "description": "Top-level input fields for creating a transaction by authorizing a Venmo account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of a Venmo payment method to be authorized.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields related to the Venmo account being authorized.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AuthorizeVenmoAccountOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the authorization, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "AuthorizeVenmoAccountOptionsInput", - "description": "Input fields for authorizing a Venmo account.", - "fields": null, - "inputFields": [ - { - "name": "profileId", - "description": "Specifies which Venmo business profile to use for the transaction.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "AuthorizedEvent", - "description": "Accompanying information for an authorized transaction.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction was authorized.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response to the authorization request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionAuthorizationProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "networkResponse", - "description": "Fields describing the network response to the authorization request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentNetworkResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "riskDecision", - "description": "Risk decision for this transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "RiskDecision", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authorizationExpiresAt", - "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).", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "description": "Response codes from the processing bank's Address Verification System (AVS) and CVV verification.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BYPASS", - "description": "AVS or CVV checks were skipped via the API.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DOES_NOT_MATCH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ISSUER_DOES_NOT_PARTICIPATE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MATCHES", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NOT_APPLICABLE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NOT_PROVIDED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NOT_VERIFIED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SYSTEM_ERROR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "BinRecord", - "description": "Information about the credit card based on its BIN.", - "fields": [ - { - "name": "prepaid", - "description": "Whether or not the card is prepaid, such as a gift card.", - "args": [], - "type": { - "kind": "ENUM", - "name": "BinRecordValue", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "healthcare", - "description": "Whether the card is designated only to be used for healthcare expenses.", - "args": [], - "type": { - "kind": "ENUM", - "name": "BinRecordValue", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "debit", - "description": "Whether or not the card is a debit card.", - "args": [], - "type": { - "kind": "ENUM", - "name": "BinRecordValue", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "durbinRegulated", - "description": "Whether the card is regulated by the Durbin Amendment due to the bank's assets, and therefore has a maximum interchange rate.", - "args": [], - "type": { - "kind": "ENUM", - "name": "BinRecordValue", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "commercial", - "description": "Whether or not the card is a commercial card and capable of processing Level 2 transactions.", - "args": [], - "type": { - "kind": "ENUM", - "name": "BinRecordValue", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payroll", - "description": "Whether or not the card is designated for employee wages.", - "args": [], - "type": { - "kind": "ENUM", - "name": "BinRecordValue", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "issuingBank", - "description": "The name of the bank that issued the card.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "countryOfIssuance", - "description": "The country code of the country that issued the card.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "productId", - "description": "A code representing any special program from the card issuer the card is part of.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "BinRecordValue", - "description": "A boolean-like value that includes `UNKNOWN` in the case where the information isn't available.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "NO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNKNOWN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "YES", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "No", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "Unknown", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "Yes", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Boolean", - "description": "Built-in Boolean", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "BraintreeApiConfiguration", - "description": "Configuration for payment methods in legacy clients.", - "fields": [ - { - "name": "url", - "description": "The URL for tokenizing payment methods.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "accessToken", - "description": "The authentication for tokenizing payment methods.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "BusinessAccountCreationRequest", - "description": "Record of onboarding request.", - "fields": [ - { - "name": "id", - "description": "Unique identifier generated by PayPal for the onboarding request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAccount", - "description": "Information about the merchant account that is being created as a result of the request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MerchantAccount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "creationStatus", - "description": "The account creation status for this account.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AccountCreationStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "BusinessAccountCreationRequestConnection", - "description": "A paginated list of BusinessAccountCreationRequests.", - "fields": [ - { - "name": "edges", - "description": "A list of BusinessAccountCreationRequests.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "BusinessAccountCreationRequestConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of BusinessAccountCreationRequests contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "BusinessAccountCreationRequestConnectionEdge", - "description": "A BusinessAccountCreationRequest within a BusinessAccountCreationRequestConnection.", - "fields": [ - { - "name": "cursor", - "description": "This BusinessAccountCreationRequest's location within the BusinessAccountCreationRequestConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The business account creation request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "BusinessAccountCreationRequest", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "BusinessAccountCreationRequestSearchInput", - "description": "Input fields for searching for BusinessAccountCreationRequests.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find BusinessAccountCreationRequests with an ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "externalId", - "description": "Find BusinessAccountCreationRequests by their external ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "status", - "description": "Find BusinessAccountCreationRequests by their creation status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AccountCreationStatusSearchInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "BusinessType", - "description": "The type of the business.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "GOVERNMENT_AGENCY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LIMITED_LIABILITY_CORPORATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NONPROFIT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PARTNERSHIP", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PARTNERSHIP_LLP", - "description": null, - "isDeprecated": true, - "deprecationReason": "No longer applicable, use PARTNERSHIP instead." - }, - { - "name": "PRIVATE_CORPORATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PUBLIC_CORPORATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SOLE_PROPRIETORSHIP", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TAX_EXEMPT", - "description": null, - "isDeprecated": true, - "deprecationReason": "No longer applicable, use NONPROFIT instead." - } - ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "CVV", - "description": "A three- or four-digit string CVV (card verification value), otherwise known as CSC or CVC.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CaptureTransactionInput", - "description": "Top-level input fields for capturing an authorized transaction.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "ID of the transaction to be captured.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "amount", - "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.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the capture, with details that will define the resulting transaction.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CaptureTransactionOptionsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CaptureTransactionOptionsInput", - "description": "Input fields for a capture, with details that will define the resulting transaction.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "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.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "descriptor", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionDescriptorInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "discountAmount", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lineItems", - "description": "Line items for this transaction. Up to 249 line items may be specified.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionLineItemInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "orderId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "purchaseOrderNumber", - "description": "A purchase order identification value you associate with this transaction.\n\n*Required for Level 2 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shipping", - "description": "Shipping information.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionShippingInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "tax", - "description": "Tax information about the transaction.\n\n*Required for Level 2 processing*.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionTaxInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "CardAccountType", - "description": "The type of account to be used when transacting with a combo card.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CREDIT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DEBIT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CardPresentOriginDetails", - "description": "Additional information about a card present payment method supplied by an in-store payment reader.", - "fields": [ - { - "name": "authorizationMode", - "description": "The authorization mode used to perform the transaction on the payment reader.", - "args": [], - "type": { - "kind": "ENUM", - "name": "InStoreReaderAuthorizationMode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pinVerified", - "description": "An indicator for whether the transaction was verified via pin.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inputMode", - "description": "The input mode used on the payment reader to facilitate an in-store transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentReaderInputMode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminalId", - "description": "The ID of the terminal that was processed this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "InStoreReaderOriginDetails", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "Challenge", - "description": "A list of challenges that are required by the current merchant to process a given credit card.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CVV", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "POSTAL_CODE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cvv", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "postal_code", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargeCreditCardInput", - "description": "Top-level input fields for creating a transaction by charging a credit card.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of a credit card payment method to be charged.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields for creating a credit card transaction.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CreditCardTransactionOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the charge, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargePayPalAccountInput", - "description": "Top-level input fields for creating a transaction by charging a PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "The ID of an existing PayPal account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields related to the PayPal account being charged.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ChargePayPalAccountOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the charge, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargePayPalAccountOptionsInput", - "description": "Input fields for creating a transaction with a PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "customField", - "description": "Variable passed directly to PayPal for your own tracking purposes. Customers do not see this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "description", - "description": "Description of the transaction that is displayed to customers in PayPal email receipts.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "payee", - "description": "Deprecated: This field is no longer supported.", - "type": { - "kind": "INPUT_OBJECT", - "name": "PayPalPayeeOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "selectedFinancingOption", - "description": "Buyer selected PayPal financing option.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SelectedPayPalFinancingOptionInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargePaymentMethodInput", - "description": "Top-level input fields for creating a transaction by charging a payment method.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of a payment method to be charged.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the charge, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargeUsBankAccountInput", - "description": "Top-level input fields for creating a transaction by charging a US bank account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "The ID of an existing US bank account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields related to the US bank account being charged.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ChargeUsBankAccountOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the charge, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargeUsBankAccountOptionsInput", - "description": "Input fields for creating a transaction with a US bank account.", - "fields": null, - "inputFields": [ - { - "name": "standardEntryClassCode", - "description": "A NACHA standard entry class (SEC) code, which designates how the transaction was authorized. Most internet-based sales should use the `WEB` code.", - "type": { - "kind": "ENUM", - "name": "ACHStandardEntryClassCode", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargeVenmoAccountInput", - "description": "Top-level input fields for creating a transaction by charging a Venmo account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "The ID of an existing Venmo account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields for creating a Pay with Venmo transaction.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ChargeVenmoAccountOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the charge, with details that will define the resulting transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ChargeVenmoAccountOptionsInput", - "description": "Input fields for creating a Pay with Venmo transaction.", - "fields": null, - "inputFields": [ - { - "name": "profileId", - "description": "Specifies which Venmo business profile to use for the transaction.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ChargebackProtectionLevel", - "description": "The chargeback protection level indicates the transaction or dispute's protection status.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "EFFORTLESS", - "description": "The transaction or dispute is protected by the effortless chargeback protection product.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NOT_PROTECTED", - "description": "The merchant has not enrolled any chargeback protection products, or the merchant is registered, but the transaction or dispute is not protected.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "STANDARD", - "description": "The transaction or dispute is protected by the standard chargeback protection product.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ChildCapture", - "description": "A partial capture's relationship to its original authorization transaction.", - "fields": [ - { - "name": "parentAuthorization", - "description": "The original authorization whose funds have been partially captured.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ClientConfiguration", - "description": "Top-level fields returned from the client configuration query.", - "fields": [ - { - "name": "analyticsUrl", - "description": "URL to send analytics.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting SDKs that send analytics." - }, - { - "name": "applePay", - "description": "Configuration for Apple Pay on iOS.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "ApplePayConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "applePayWeb", - "description": "Configuration for Apple Pay on the web.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "ApplePayWebConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "assetsUrl", - "description": "A URL pointing to the base path of Braintree's web pages used for various browser switches and popups.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "clientApiUrl", - "description": "A URL pointing to the base path of Braintree's client API.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting legacy clients." - }, - { - "name": "supportedFeatures", - "description": "A list of client features the merchant supports.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "ClientFeature", - "ofType": null - } - } - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting legacy clients." - }, - { - "name": "braintreeApi", - "description": "Configuration for payment methods in legacy clients.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "BraintreeApiConfiguration", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting legacy clients." - }, - { - "name": "creditCard", - "description": "Configuration for credit card tokenization.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "CreditCardConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "environment", - "description": "The enum of the current environment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ClientConfigurationEnvironment", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fraudProvider", - "description": "Configuration for fraud protection provider.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "FraudProviderConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "googlePay", - "description": "Configuration for Google Pay on Android and the web.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "GooglePayConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ideal", - "description": "Deprecated, this field will always be null.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "IDealConfiguration", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting legacy clients." - }, - { - "name": "kount", - "description": "Deprecated, formerly configuration for Kount fraud tools, now this configuration lives under fraudProvider.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "KountConfiguration", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting legacy clients." - }, - { - "name": "masterpass", - "description": "Configuration for Masterpass.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MasterpassConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantId", - "description": "The merchant ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paypal", - "description": "Configuration for PayPal.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PayPalConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "samsungPay", - "description": "Configuration for Samsung Pay.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "SamsungPayConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "unionPay", - "description": "Configuration for UnionPay cards.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "UnionPayConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "usBankAccount", - "description": "Configuration for US bank account processing.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "UsBankAccountConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "venmo", - "description": "Configuration for Pay with Venmo.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "VenmoConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "visaCheckout", - "description": "Configuration for Visa Checkout.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "VisaCheckoutConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "challenges", - "description": "A list of challenges that are required by the current merchant to process a given credit card.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "Challenge", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ClientConfigurationEnvironment", - "description": "The client configuration environment being used.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "DEVELOPMENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRODUCTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "QA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SANDBOX", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TEST", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "development", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "production", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "qa", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sandbox", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "test", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ClientFeature", - "description": "A value used by Braintree client SDKs to determine what operations are supported through this GraphQL API.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "TOKENIZE_CREDIT_CARDS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenize_credit_cards", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ClientTokenInput", - "description": "Input fields for creating a client token.", - "fields": null, - "inputFields": [ - { - "name": "merchantAccountId", - "description": "The merchant account ID used to create the client token. Defaults to your default merchant account ID.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "The ID of an existing customer. Including this will allow your customer to vault and manage their payment methods.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ConfirmMicroTransferAmountsInput", - "description": "Top-level input field for confirming micro-transfer values.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "verificationId", - "description": "The ID of the verification from vaulting the bank account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "amountsInCents", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - } - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ConfirmMicroTransferAmountsPayload", - "description": "Top-level output field from confirming micro-transfer amounts on bank account.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verification", - "description": "The verification that was run on the payment method prior to vaulting.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Verification", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the micro-transfer amounts confirmation.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ConfirmMicroTransferAmountsStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ConfirmMicroTransferAmountsStatus", - "description": "The status of a micro-transfer amount confirmation.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AMOUNTS_DO_NOT_MATCH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CONFIRMED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TOO_MANY_ATTEMPTS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ConfirmationPromptAlignment", - "description": "The alignment of the confirmation prompt text when displayed on the in-store reader.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CENTER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LEFT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "CountryCode", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "CountryCodeAlpha2", - "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).", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreateClientTokenInput", - "description": "Top-level input field for generating a client token.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "clientToken", - "description": "Input fields for creating a client token.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ClientTokenInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreateClientTokenPayload", - "description": "Top-level fields returned when creating a client token.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "clientToken", - "description": "A Base64 encoded string used to initialize client SDKs.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreateCustomerInput", - "description": "Top-level field for creating a customer.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customer", - "description": "Input fields for creating a customer.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CustomerInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreateCustomerPayload", - "description": "Top-level fields returned when creating a customer.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customer", - "description": "Information about the customer that was created. Can be used when vaulting payment methods or creating transactions to associate those objects.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Customer", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreateDisputeFileEvidenceInput", - "description": "Top-level input fields for adding file evidence to a dispute.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disputeId", - "description": "The ID of the dispute to be accepted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "category", - "description": "The category for the evidence file.", - "type": { - "kind": "ENUM", - "name": "DisputeFileEvidenceCategory", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreateDisputeFileEvidencePayload", - "description": "Top-level field returned when creating file evidence for a dispute.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "evidence", - "description": "The evidence object created.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "DisputeFileEvidence", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "dispute", - "description": "Information about the dispute the evidence is attached to.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Dispute", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreateDisputeTextEvidenceInput", - "description": "Top-level input fields for creating text evidence for a dispute.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disputeId", - "description": "The ID of the dispute to create the evidence for.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "category", - "description": "The category of the text evidence.", - "type": { - "kind": "ENUM", - "name": "DisputeTextEvidenceCategory", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "content", - "description": "The content of the text evidence.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreateDisputeTextEvidencePayload", - "description": "Top-level field returned when creating text evidence for a dispute.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "evidence", - "description": "The evidence object created.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "DisputeTextEvidence", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreateInStoreLocationInput", - "description": "Input fields for creating an in store location.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "location", - "description": "Input fields to create an in-store Location.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreateInStoreLocationPayload", - "description": "Top-level fields returned when creating an in-store location.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "location", - "description": "The in-store location.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreLocation", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreateNonInstantLocalPaymentContextInput", - "description": "Top-level input fields for creating a non-instant local payment context.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentContext", - "description": "Input fields for creating a non-instant local payment context.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "NonInstantLocalPaymentContextInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreateNonInstantLocalPaymentContextPayload", - "description": "The result of a request to make a local payment context.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentContext", - "description": "Details about the local payment context.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "LocalPaymentContext", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreatePayPalBillingAgreementInput", - "description": "Top-level input field for creating a PayPal Billing Agreement Token.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "Braintree merchant account ID associated with the PayPal account to be used for the Billing Agreement creation.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "returnUrl", - "description": "URL for redirect back to merchant app on the client indicating successful approval.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "URL", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "cancelUrl", - "description": "URL for redirect back to merchant app on the client indicating unsuccessful approval.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "URL", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "description", - "description": "Description of the PayPal Billing Agreement, displayed to the PayPal user on paypal.com and other PayPal user experiences.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "email", - "description": "Email of the payer (if known). This will prepopulate the input field in the PayPal approval page.", - "type": { - "kind": "SCALAR", - "name": "EmailAddress", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "offerPayPalCredit", - "description": "Indicates whether PayPal Credit should be offered in the PayPal approval flow.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paypalRiskCorrelationId", - "description": "PayPal Risk correlation ID (also known as the Client Metadata ID).", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name" : "paypalExperienceProfile", - "description" : "Defines the experience profile used to render the billing agreement approval flow.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "PayPalBillingAgreementExperienceProfileInput", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "shippingAddress", - "description" : "Merchant-provided shipping address. Fields addressLine1, adminArea2, and countryCode are required for Billing Agreements.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "AddressInput", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "paypalProductAttributes", - "description" : "Product attributes input for PayPal billing agreement.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "PayPalProductAttributesInput", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreatePayPalBillingAgreementPayload", - "description": "Top-level fields returned from setting up a PayPal Billing Agreement Token.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAgreementToken", - "description": "The Billing Agreement token.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "approvalUrl", - "description": "The URL for getting user approval of the PayPal Billing Agreement.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "URL", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreatePayPalOneTimePaymentInput", - "description": "Top-level input field for creating a PayPal One-Time Payment.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "Braintree merchant account ID associated with the PayPal account to be used for the One-Time payment creation.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "Total amount for payment to be charged to consumer.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "cancelUrl", - "description": "URL for redirect back to merchant app on the client indicating unsuccessful approval.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "URL", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "email", - "description": "Email of the payer. This will prepopulate the input field in the PayPal approval login page.", - "type": { - "kind": "SCALAR", - "name": "EmailAddress", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "intent", - "description": "The payment intent.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PayPalIntent", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "lineItems", - "description": "The line items for this transaction. Maximum 249 line items.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PayPalLineItemInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "offerPayLater", - "description": "Indicates whether PayPal Pay Later should be offered in the PayPal approval flow.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paypalRiskCorrelationId", - "description": "PayPal Risk correlation ID (also known as the Client Metadata ID).", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paypalExperienceProfile", - "description": "Defines the experience profile used to render the approval flow.", - "type": { - "kind": "INPUT_OBJECT", - "name": "PayPalExperienceProfileInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "requestBillingAgreement", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAgreementDescription", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "returnUrl", - "description": "URL for redirect back to merchant app on the client indicating successful approval.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "URL", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "shippingAddress", - "description": "Merchant-provided shipping address. If passing a shipping address, fields addressLine1, adminArea2, and countryCode are required.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingOptions", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PayPalShippingOptionInput", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreatePayPalOneTimePaymentPayload", - "description": "Top-level fields returned from setting up a PayPal One-Time Payment.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "approvalUrl", - "description": "The URL for getting user approval of the PayPal payment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "URL", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentId", - "description": "The PayPal payment ID. This ID is prefixed with \"PAYID-\".", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreateUniversalAccessTokenInput", - "description": "Top-level input field for generating a PayPal access token.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "The ID of an existing customer. Including this will allow the access token to interact with this customer's data.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "type", - "description": "Authentication context class reference for the universal access token.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "ACRType", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreateUniversalAccessTokenPayload", - "description": "Top-level fields returned when creating a universal access token.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "accessToken", - "description": "The created universal access token.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "AccessToken", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "description": "A code identifying the card brand.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AMERICAN_EXPRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CITI", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DINERS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DISCOVER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ELO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "HIPER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "HIPERCARD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INTERNATIONAL_MAESTRO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "JCB", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MASTERCARD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SOLO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SWITCH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UK_MAESTRO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNION_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNKNOWN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VISA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "american_express", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "citi", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "diners", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "discover", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "elo", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hiper", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hipercard", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "international_maestro", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "jcb", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mastercard", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "solo", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "switch", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "uk_maestro", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "union_pay", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "unknown", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "visa", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreditCardConfiguration", - "description": "Configuration for credit card tokenization.", - "fields": [ - { - "name": "supportedCardBrands", - "description": "A list of card brands supported by the merchant for credit card processing.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "challenges", - "description": "A list of challenges that are required by the merchant to process a given credit card.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "Challenge", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "threeDSecureEnabled", - "description": "Whether or not the merchant supports 3D Secure.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `threeDSecure` instead." - }, - { - "name": "threeDSecure", - "description": "Configuration for 3D Secure.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "ThreeDSecureConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fraudDataCollectionEnabled", - "description": "Whether or not fraud data collection is enabled for the merchant.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreditCardDetails", - "description": "Details about a credit card.", - "fields": [ - { - "name": "brandCode", - "description": "A static code identifying the card brand.", - "args": [], - "type": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "last4", - "description": "The last four digits of the card number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "binData", - "description": "Information about the card based on its BIN.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "BinRecord", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "expirationMonth", - "description": "The month of the expiration date, formatted MM.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "expirationYear", - "description": "The year of the expiration date, formatted YYYY.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cardholderName", - "description": "The cardholder's name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "uniqueNumberIdentifier", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "origin", - "description": "Additional information if the credit card was provided from a third-party origin, such as Apple Pay, Google Pay, or another digital wallet.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethodOrigin", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAddress", - "description": "The billing address associated with the credit card.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "threeDSecure", - "description": "3D Secure information for the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "ThreeDSecureDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "imageUrl", - "description": "A URL to an image logo representing the card brand.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting legacy clients." - }, - { - "name": "brand", - "description": "The display name of the card brand, e.g. \"Visa\" or \"American Express\".", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `brandCode` instead." - }, - { - "name": "cardOnFileNetworkTokenized", - "description": "Indicates whether the card on file is network tokenized.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreditCardFraudToolsOptionsInput", - "description": "Input fields that allow you to skip certain fraud checks. These will override Control Panel settings.", - "fields": null, - "inputFields": [ - { - "name": "skipCvv", - "description": "Skip CVV checks. Will result in a `cvvResponse` of `BYPASS` in the response from the processor.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "skipAvs", - "description": "Skip AVS checks. Will result in an `avsPostalCodeResponse` of `BYPASS` in the response from the processor.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "skipAdvancedFraudChecking", - "description": "Skip [advanced fraud checks](https://developers.braintreepayments.com/guides/advanced-fraud-management-tools/overview).", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreditCardInput", - "description": "Input fields for a credit card.", - "fields": null, - "inputFields": [ - { - "name": "number", - "description": "The 12-to-19-digit value that uniquely identifies this credit card, also known as the primary account number or PAN.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "expirationYear", - "description": "The two- or four-digit year associated with a credit card, formatted `YYYY` or `YY`.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "expirationMonth", - "description": "The expiration month of a credit card, formatted `MM`.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cvv", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cardholderName", - "description": "When supplied, the cardholder name that will be tokenized with the contents of the fields.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "description": "The billing address for the credit card.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "CreditCardLast4", - "description": "A four-digit string.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "CreditCardNumber", - "description": "A number that passes Luhn validation.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreditCardTransactionDetails", - "description": "Credit card specific details on a transaction or verification.", - "fields": [ - { - "name": "creditCard", - "description": "The details of the credit card itself.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "CreditCardDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "networkTransactionId", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "accountType", - "description": "For combo cards, what account type was used for this specific transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "CardAccountType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "acquirerReferenceNumber", - "description": "Reference value assigned to a card transaction once it has been processed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processedWithCardOnFileNetworkToken", - "description": "Indicates whether the transaction was processed with a card on file network token.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "accountBalance", - "description": "The remaining balance in the account after this transaction. This field is only returned for payment methods such as prepaid cards.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreditCardTransactionOptionsInput", - "description": "Input fields for creating a transaction by authorizing or charging a credit card.", - "fields": null, - "inputFields": [ - { - "name": "externalVault", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionExternalVaultOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountType", - "description": "The type of account to be used when transacting with a combo card.", - "type": { - "kind": "ENUM", - "name": "CardAccountType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "tokenizedCvv", - "description": "The CVV for the credit card to be used when creating this transction, securely tokenized with the `tokenizeCvv` mutation.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "fraudTools", - "description": "Control which fraud tools will be applied to this transaction. Fraud tools cannot be retroactively applied to a transaction if skipped.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CreditCardFraudToolsOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "threeDSecureAuthentication", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureAuthenticationInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "scaExemption", - "description": "The type of Strong Customer Authentication Exemption requested.", - "type": { - "kind": "ENUM", - "name": "ScaExemptionType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "installmentCount", - "description": "Number of monthly installments (can be anywhere between 2 and 12).", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CreditCardVerificationDetails", - "description": "Information specific to verifications of credit card payment methods.", - "fields": [ - { - "name": "amount", - "description": "The amount used when performing the verification. May be 0.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CreditCardVerificationOptionsInput", - "description": "Input fields that specify options for verifying the credit card.", - "fields": null, - "inputFields": [ - { - "name": "merchantAccountId", - "description": "Deprecated: Please use `merchantAccountId` in the base input instead.\n\nID of the merchant account to use when verifying the credit card.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountType", - "description": "The type of account to be used when verifying a combo card.", - "type": { - "kind": "ENUM", - "name": "CardAccountType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "riskData", - "description": "Customer device information, which is sent directly to supported processors for fraud analysis.", - "type": { - "kind": "INPUT_OBJECT", - "name": "RiskDataInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "fraudTools", - "description": "Control which fraud tools will be applied to this verification. Fraud tools cannot be retroactively applied to a verification if skipped.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CreditCardFraudToolsOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "tokenizedCvv", - "description": "The CVV for the credit card to be used when verifying the credit card, securely tokenized with the `tokenizeCvv` mutation.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The amount to use to verify the credit card.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "skip", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "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).", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CustomActionsPaymentContext", - "description": "Top-level fields returned from a Custom Actions payment context.", - "fields": [ - { - "name": "id", - "description": "The identifier of the payment context.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the payment context was created.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updatedAt", - "description": "Date and time when the payment context was updated.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customFields", - "description": "A list of fields stored on a PaymentContext during execution of a Custom Actions handler (Five (5) entries maximum).", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "CustomActionsPaymentContextField", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "PaymentContext", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CustomActionsPaymentContextField", - "description": "Fields returned by the createPaymentContext custom actions event handler.", - "fields": [ - { - "name": "name", - "description": "An alphanumeric string used as a key to lookup a CustomField value (255 characters maximum).", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "value", - "description": "An alphanumeric string used to store a CustomField value (7168 characters maximum).", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CustomActionsPaymentContextFieldInput", - "description": "Fields that are provided when creating the payment context.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "An alphanumeric string used as a key to lookup a CustomField value (255 characters maximum).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "value", - "description": "An alphanumeric string used to store a CustomField value (7168 characters maximum).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CustomActionsPaymentMethodDetails", - "description": "Details about a custom actions payment method.", - "fields": [ - { - "name": "actionName", - "description": "The action to be invoked when using the payment method.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fields", - "description": "Fields that your action requires.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "CustomActionsPaymentMethodField", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CustomActionsPaymentMethodField", - "description": "Fields that are provided during tokenization and are presented to the invoked action to be consumed.", - "fields": [ - { - "name": "name", - "description": "The name of this field, e.g. \"accountNumber\".", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "displayValue", - "description": "The value displayed in the Control Panel or API, e.g. \"*****6789\".", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CustomActionsPaymentMethodFieldInput", - "description": "Fields that are provided during tokenization and are presented to the invoked action to be consumed.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "The name of this field. e.g. \"accountNumber\".", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "value", - "description": "The value of this field. e.g. \"123456789\".", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "displayValue", - "description": "The value displayed in the Control Panel or API. e.g. \"*****6789\".", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CustomActionsPaymentMethodInput", - "description": "Input fields for a Custom Actions payment method.", - "fields": null, - "inputFields": [ - { - "name": "actionName", - "description": "The action you wish to invoke when using the tokenized payment method.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "fields", - "description": "Fields that your action requires.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomActionsPaymentMethodFieldInput", - "ofType": null - } - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CustomField", - "description": "A merchant-defined custom field to store additional information.", - "fields": [ - { - "name": "name", - "description": "The name of the custom field.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "value", - "description": "The value of the custom field.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CustomFieldInput", - "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.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "Name of the custom field as defined in the Control Panel.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CustomFieldName", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "value", - "description": "Value for the named custom field. A null value will ignore (on create) or remove (on update) the custom field.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "CustomFieldName", - "description": "A string representing a custom field value. Contains letters, numbers, and underscores.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Customer", - "description": "Information about a customer and their associated payment methods and transactions.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "company", - "description": "Company or business name associated with this customer.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time at which the customer was created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customFields", - "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).", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "CustomField", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "defaultPaymentMethod", - "description": "Customer's default payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "email", - "description": "Email address for this customer.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "firstName", - "description": "Customer's first name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "lastName", - "description": "Customer's last name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "phoneNumber", - "description": "The phone number for this customer.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethods", - "description": "Payment methods belonging to this customer.", - "args": [ - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PaymentMethodConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactions", - "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.", - "args": [ - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "CustomerAuthenticationIndicator", - "description": "A value indicating when to perform further customer authentication.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "OPTIONAL", - "description": "Indicates further authentication is optional.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REQUIRED", - "description": "Indicates further authentication should be performed.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNAVAILABLE", - "description": "Customer authentication indicator information is unavailable at this time.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "CustomerAuthenticationRegulationEnvironment", - "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/).", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "PSDTWO", - "description": "EU Regulation [PSD2 Strong Customer Authentication](https://www.braintreepayments.com/blog/understanding-and-preparing-for-psd2-strong-customer-authentication/) applies to this transaction.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RBI", - "description": "Reserve Bank of India regulations apply to this transactions.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNAVAILABLE", - "description": "Customer authentication regulation environment information is unavailable for this transaction at this time.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNREGULATED", - "description": "No customer authentication regulations apply to this transaction.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CustomerConnection", - "description": "A paginated list of customers.", - "fields": [ - { - "name": "edges", - "description": "A list of customers.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "CustomerConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of customers contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "CustomerConnectionEdge", - "description": "A customer within a CustomerConnection.", - "fields": [ - { - "name": "cursor", - "description": "This customer's location within the CustomerConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The customer.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Customer", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CustomerInput", - "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.", - "fields": null, - "inputFields": [ - { - "name": "company", - "description": "Company or business name associated with the customer.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customFields", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomFieldInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "email", - "description": "Email address for the customer.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "firstName", - "description": "Customer's first name.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lastName", - "description": "Customer's last name.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "The customer's phone number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "taxIdentifiers", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomerTaxIdentifierInput", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CustomerSearchInput", - "description": "Input fields for searching for customers.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find customers with an id or ids.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "company", - "description": "Find customers with a given company or business name.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "createdAt", - "description": "Find customers with a given created at time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "email", - "description": "Find customers with a given email address.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "firstName", - "description": "Find customers with a given first name.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lastName", - "description": "Find customers with a given last name.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "Find customers with a given phone number.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "CustomerTaxIdentifierInput", - "description": "The customer's tax identifer for a given tax jurisdiction.", - "fields": null, - "inputFields": [ - { - "name": "identifier", - "description": "The identifier provided in the format required for the given tax jurisdiction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "The country code of the tax jurisdiction for this tax identifier.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Date", - "description": "A date in the format YYYY-MM-DD.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "DeleteCustomerInput", - "description": "Top-level input fields for deleting a customer.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "The ID of the customer to be deleted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DeleteCustomerPayload", - "description": "Top-level output field from deleting a customer.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "DeleteDisputeEvidenceInput", - "description": "Input fields for deleting dispute evidence.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "evidenceId", - "description": "The ID of the evidence to be deleted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "disputeId", - "description": "The ID of the dispute that the evidence belongs to.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DeleteDisputeEvidencePayload", - "description": "Top-level field returned when deleting evidence from a dispute.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "DeletePaymentMethodFromSingleUseTokenInput", - "description": "Top-level input fields for deleting a payment method referenced by a single-use token.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "singleUseTokenId", - "description": "A single-use token ID referencing a payment method.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DeletePaymentMethodFromSingleUseTokenPayload", - "description": "Top-level output field from deleting a payment method referenced by a single-use token.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "DeletePaymentMethodFromVaultInput", - "description": "Top-level input fields for deleting a multi-use payment method from the vault.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "The ID of the multi-use payment method to be deleted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "initiatedBy", - "description": "Indicates whether this deletion was initiated by the merchant or the customer (via the merchant site/app).", - "type": { - "kind": "ENUM", - "name": "PaymentMethodDeletionInitiator", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "deleteRelatedPaymentMethods", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "fraudRelated", - "description": "Indicates if this deletion is related to suspected fraud, as determined by the merchant.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DeletePaymentMethodFromVaultPayload", - "description": "Top-level output field from deleting a multi-use payment method.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "DetachedRefundInput", - "description": "Specific input fields for describing a detached refund.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "description": "The amount to refund.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "orderId", - "description": "The refund's order ID.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the merchant account that will be used when performing the refund.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customFields", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomFieldInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "descriptor", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionDescriptorInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisbursementBankAccount", - "description": "Details about the disbursement bank account.", - "fields": [ - { - "name": "last4", - "description": "The last four digits of the bank account number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "routingNumber", - "description": "The routing number of the bank.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisbursementDetails", - "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.", - "fields": [ - { - "name": "date", - "description": "The date that the funds associated with this transaction were disbursed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "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).", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "exchangeRate", - "description": "The exchange rate from the presentment currency to the settlement currency. If the currencies are the same, this will be 1.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fundsHeld", - "description": "Indicates whether funds have been withheld from a disbursement to the merchant's account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "DisplayItemType", - "description": "The display item type to be displayed on the in-store reader.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CHARGE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DISCOUNT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LINE_BREAK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TEXT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Dispute", - "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.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amountDisputed", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amountWon", - "description": "If an amount was disputed, the amount of money awarded back to the merchant if the dispute was reversed.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "caseNumber", - "description": "The case number for the dispute.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time at which the dispute was created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "receivedDate", - "description": "Date the dispute was received by the merchant.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "referenceNumber", - "description": "The transaction reference number for the dispute.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "responseDeadline", - "description": "The deadline for the merchant to submit a response to the dispute.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "replyByDate", - "description": "The reply by date for the merchant to submit a response to the dispute.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "The type of dispute.", - "args": [], - "type": { - "kind": "ENUM", - "name": "DisputeType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "evidence", - "description": "Evidence records submitted by the merchant for the dispute.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INTERFACE", - "name": "DisputeEvidence", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "originalDispute", - "description": "If this dispute is a follow-up to a previous chargeback or retrieval, the original dispute.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Dispute", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Additional information from the payment processor.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "DisputeProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the dispute.", - "args": [], - "type": { - "kind": "ENUM", - "name": "DisputeStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "statusHistory", - "description": "A log of history events containing status changes by date for this dispute.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "DisputeStatusEvent", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transaction", - "description": "The disputed transaction which the customer is either requesting further information on or challenging.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "chargebackProtectionLevel", - "description": "The chargeback protection status of the dispute.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ChargebackProtectionLevel", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `protectionLevel` instead." - }, - { - "name" : "protectionLevel", - "description" : "The protection level of the dispute.", - "args" : [], - "type" : { - "kind" : "ENUM", - "name" : "DisputeProtectionLevel", - "ofType" : null - }, - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "preDisputeProgram", - "description" : "The pre-dispute program of the dispute.", - "args" : [], - "type" : { - "kind" : "ENUM", - "name" : "PreDisputeProgram", - "ofType" : null - }, - "isDeprecated" : false, - "deprecationReason" : null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisputeConnection", - "description": "A paginated list of disputes.", - "fields": [ - { - "name": "edges", - "description": "A list of disputes.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "DisputeConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of disputes contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisputeConnectionEdge", - "description": "A dispute within a DisputeConnection.", - "fields": [ - { - "name": "cursor", - "description": "This dispute's location within the DisputeConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The dispute.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Dispute", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INTERFACE", - "name": "DisputeEvidence", - "description": "Evidence provided by a merchant to respond to a dispute.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the evidence was created with Braintree.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sentToProcessorAt", - "description": "Date and time when the evidence was sent to the processor.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "category", - "description": "The evidence category.", - "args": [], - "type": { - "kind": "ENUM", - "name": "DisputeEvidenceCategory", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "DisputeFileEvidence", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "DisputeTextEvidence", - "ofType": null - } - ] - }, - { - "kind": "ENUM", - "name": "DisputeEvidenceCategory", - "description": "The evidence category that specifies which requirement it satisfies.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AVS_RESPONSE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CARRIER_NAME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CARRIER_NAME_OTHER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_ISSUED_AMOUNT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_ISSUED_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DEVICE_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DEVICE_NAME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DOWNLOAD_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "EVIDENCE_TYPE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GENERAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GEOGRAPHICAL_LOCATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LEGIT_PAYMENTS_FOR_SAME_MERCHANDISE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MERCHANT_WEBSITE_OR_APP_ACCESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_DIGITAL_GOODS_TRANSACTION_ARN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_DIGITAL_GOODS_TRANSACTION_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_DIGITAL_GOODS_TRANSACTION_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_ARN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_EMAIL_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_IP_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_PHONE_NUMBER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_PHYSICAL_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROFILE_SETUP_OR_APP_ACCESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_3D_SECURE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_AUTHORIZED_SIGNER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_DELIVERY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_DELIVERY_EMP_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_POSSESSION_OR_USAGE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PURCHASER_EMAIL_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PURCHASER_IP_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PURCHASER_NAME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING_TRANSACTION_ARN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING_TRANSACTION_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING_TRANSACTION_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REFUND_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SIGNED_DELIVERY_FORM", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SIGNED_ORDER_FORM", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TICKET_PROOF", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRACKING_NUMBER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRACKING_URL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisputeFileEvidence", - "description": "Images, files, or other evidence supporting a dispute case.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time at which the evidence was created with Braintree.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sentToProcessorAt", - "description": "Date and time at which the evidence was sent to the processor.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": "A URL where you can retrieve the dispute evidence.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "category", - "description": "The evidence category.", - "args": [], - "type": { - "kind": "ENUM", - "name": "DisputeEvidenceCategory", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "DisputeEvidence", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "DisputeFileEvidenceCategory", - "description": "For file evidence: the evidence category that specifies which requirement it satisfies.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "GENERAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LEGIT_PAYMENTS_FOR_SAME_MERCHANDISE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MERCHANT_WEBSITE_OR_APP_ACCESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROFILE_SETUP_OR_APP_ACCESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_3D_SECURE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_AUTHORIZED_SIGNER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_DELIVERY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_DELIVERY_EMP_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROOF_OF_POSSESSION_OR_USAGE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SIGNED_DELIVERY_FORM", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SIGNED_ORDER_FORM", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TICKET_PROOF", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisputeProcessorResponse", - "description": "Information about the dispute provided by the processor.", - "fields": [ - { - "name": "processorComments", - "description": "Additional comments forwarded by the processor.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reason", - "description": "The reason the dispute was created.", - "args": [], - "type": { - "kind": "ENUM", - "name": "DisputeReason", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reasonCode", - "description": "The reason code provided by the processor.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reasonDescription", - "description": "The reason code description based on the `reasonCode`.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "receivedDate", - "description": "Date the dispute was received by the merchant.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "referenceNumber", - "description": "The string value representing the reference number provided by the processor (if any).", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "DisputeProtectionLevel", - "description": "The Protection level indicates if dispute is eligible for protection through any feature enabled on your account.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CHARGEBACK_PROTECTION_TOOL", - "description": "The dispute is protected by the standard chargeback protection product.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "EFFORTLESS_CHARGEBACK_PROTECTION_TOOL", - "description": "The dispute is protected by the effortless chargeback protection product.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NO_PROTECTION", - "description": "The merchant has not enrolled in any chargeback protection products, or the merchant is enrolled, but the dispute is not protected.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "DisputeReason", - "description": "The reason a customer opened a chargeback, pre-arbitration, or retrieval.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CANCELLED_RECURRING_TRANSACTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_NOT_PROCESSED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DUPLICATE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAUD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GENERAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INVALID_ACCOUNT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NOT_RECOGNIZED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRODUCT_NOT_RECEIVED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRODUCT_UNSATISFACTORY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RETRIEVAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRANSACTION_AMOUNT_DIFFERS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "DisputeSearchInput", - "description": "Input fields for searching for Disputes.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find disputes with an id or ids.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "status", - "description": "Find disputes with a given status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDisputeStatusInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "type", - "description": "Find disputes with a given type.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDisputeTypeInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "reason", - "description": "Find disputes with a given reason description.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDisputeReasonInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "caseNumber", - "description": "Find disputes with a given processor's caseNumber.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "referenceNumber", - "description": "Find disputes with a given transaction referenceNumber.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amountDisputed", - "description": "Find disputes for a given amount or currency.", - "type": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountSearchInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amountWon", - "description": "Find disputes by the amount won.", - "type": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountSearchInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "receivedDate", - "description": "Find disputes by the date received.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "replyByDate", - "description": "Find disputes by the reply by date.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "effectiveDate", - "description": "Find disputes by the date a status change history event took effect.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Find disputes based on a set of transaction criteria.", - "type": { - "kind": "INPUT_OBJECT", - "name": "DisputeTransactionSearchInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "chargebackProtectionLevel", - "description": "Deprecated: Please use `protectionLevel` instead.\n\nFind disputes with a given computed chargeback protection level.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchChargebackProtectionLevelInput", - "ofType": null - }, - "defaultValue" : null - }, - { - "name" : "protectionLevel", - "description" : "Find disputes with a given protection level.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "SearchDisputeProtectionLevelInput", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "preDisputeProgram", - "description" : "Find disputes with a given pre-dispute program.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "SearchPreDisputeProgramInput", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "DisputeStatus", - "description": "The status of the dispute.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ACCEPTED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DISPUTED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "EXPIRED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOST", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OPEN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WON", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisputeStatusEvent", - "description": "A record of a status the dispute has passed through.", - "fields": [ - { - "name": "disbursementDate", - "description": "The date any funds associated with this event were disbursed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the dispute.", - "args": [], - "type": { - "kind": "ENUM", - "name": "DisputeStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the status event occurred.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "effectiveDate", - "description": "The date the status event took effect.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "DisputeTextEvidence", - "description": "Text evidence supporting a dispute case.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time at which the evidence was created with Braintree.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sentToProcessorAt", - "description": "Date and time at which the evidence was sent to the processor.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "comment", - "description": "The body for text evidence.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `content` for name instead." - }, - { - "name": "content", - "description": "The body for text evidence.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "category", - "description": "The evidence category.", - "args": [], - "type": { - "kind": "ENUM", - "name": "DisputeEvidenceCategory", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "DisputeEvidence", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "DisputeTextEvidenceCategory", - "description": "For text evidence: the evidence category that specifies which requirement it satisfies.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AVS_RESPONSE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_ISSUED_AMOUNT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_ISSUED_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DEVICE_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DEVICE_NAME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DOWNLOAD_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GEOGRAPHICAL_LOCATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_DIGITAL_GOODS_TRANSACTION_ARN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_DIGITAL_GOODS_TRANSACTION_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_DIGITAL_GOODS_TRANSACTION_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_ARN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_EMAIL_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_IP_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_PHONE_NUMBER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIOR_NON_DISPUTED_TRANSACTION_PHYSICAL_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PURCHASER_EMAIL_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PURCHASER_IP_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PURCHASER_NAME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING_TRANSACTION_ARN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING_TRANSACTION_DATE_TIME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING_TRANSACTION_ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "DisputeTransactionSearchInput", - "description": "Transaction input fields for searching for disputes.", - "fields": null, - "inputFields": [ - { - "name": "transactionId", - "description": "Find disputes for a transaction id or ids.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "Find disputes for a customer id or ids.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionSource", - "description": "Find disputes with a given transaction source.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionSourceInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodSnapshotType", - "description": "Find disputes on transactions charging payment methods of the given type.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentMethodSnapshotTypeInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "facilitatorOAuthApplicationClientId", - "description": "Find disputes on transactions created by a third party via the Grant API using a given OAuth application client ID.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disbursementDate", - "description": "Find disputes by the transaction's disbursement date.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "Find disputes on transactions associated with a merchant account ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "DisputeType", - "description": "Type of dispute.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CHARGEBACK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRE_ARBITRATION", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RETRIEVAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Duration", - "description": "An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) Duration that accepts Days, Hours, Minutes and Seconds.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "ECommerceIndicator", - "description": "A card brand-specific two-digit string describing the mode of the transaction.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "EmailAddress", - "description": "The internationalized email address.
Note: Up to 64 characters are allowed before and 255 characters are allowed after the @ sign.\nHowever, the generally accepted maximum length for an email address is 254 characters.\nThe pattern verifies that an unquoted @ sign exists.
\n\nminLength: 3\nmaxLength: 254\npattern: ^.+@[^\\\"\\\\-].+$.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "EmvCardOriginDetails", - "description": "Additional information about an integrated circuit card (ICC) payment method supplied by an in-store payment reader.", - "fields": [ - { - "name": "authorizationMode", - "description": "The authorization mode used to perform the transaction on the payment reader.", - "args": [], - "type": { - "kind": "ENUM", - "name": "InStoreReaderAuthorizationMode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pinVerified", - "description": "An indicator for whether the transaction was verified via pin.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inputMode", - "description": "The input mode used on the payment reader to facilitate an in-store transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentReaderInputMode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminalId", - "description": "The ID of the terminal that was processed this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "applicationPreferredName", - "description": "The preferred name associated with the application used to process an EMV transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "applicationIdentifier", - "description": "The identifier specifying which EMV application was used to process the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminalVerificationResult", - "description": "A status code representing the result of a series of validations performed against an EMV enabled credit card.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cardSequenceNumber", - "description": "A unique identifier for credit cards that share the same PAN.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "applicationInterchangeProfile", - "description": "An indicator of the credit card's capabilities within the processing application.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminalTransactionDate", - "description": "The local date that the transaction requested authorization from the payment reader, formatted YYMMDD.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminalTransactionType", - "description": "An indicator of the type of transaction specified during authorization processing.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cashbackAmount", - "description": "An additional amount associated with the transaction that represents the cashback amount requested by the cardholder.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "applicationUsageControl", - "description": "An indicator used to specify an issuer's restrictions for processing in a geographic region.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminalCountryCode", - "description": "The country code indicated by the payment reader to process the transaction with.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "applicationCryptogram", - "description": "The cryptogram provided by an integrated circuit card (ICC) used for processing the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cryptogramInformationData", - "description": "An indicator for the type of application cryptogram provided by an integrated circuit card (ICC) to process the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cardholderVerificationMethodResults", - "description": "An indicator of the cardholder verification method and if it was successful or unsuccessful.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "applicationTransactionCounter", - "description": "A counter managed by an integrated circuit card (ICC) that provides a reference to each transaction using that card.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "unpredictableNumber", - "description": "A value used to uniquely differentiate an application cryptogram used during authorization processing.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "issuerActionCodeDefault", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "issuerActionCodeDenial", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "issuerActionCodeOnline", - "description": "An indicator of the conditions that caused the payment reader to attempt to make a processor request.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "InStoreReaderOriginDetails", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "ExchangeRate", - "description": "A value with more than one decimal place, representing an exchange rate between currencies. For example, `0.93014065558374`.\nminLength: 3\npattern: ^\\\\d+[.]\\\\d+$", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ExchangeRateQuote", - "description": "Details of the generated exchange rate quote.", - "fields": [ - { - "name": "id", - "description": "Unique identifier, which must be passed in the payment request in order to honor the exchange rate during settlement.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "baseAmount", - "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`.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "quoteAmount", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "exchangeRate", - "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`.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ExchangeRate", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tradeRate", - "description": "This is the rate at which PayPal will settle with the merchant.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ExchangeRate", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "expiresAt", - "description": "When the exchange rate quote represents expires.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refreshesAt", - "description": "When the exchange rate quote represents will be refreshed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ExchangeRateQuoteInput", - "description": "Input to generate the exchange rate quote.", - "fields": null, - "inputFields": [ - { - "name": "baseCurrency", - "description": "The currency code from which the exchange rate will be used to convert to the `quoteCurrency`.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "quoteCurrency", - "description": "The currency code to which the exchange rate will be used to convert from `baseCurrency`.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "baseAmount", - "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.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "markup", - "description": "A percentage added into the exchange rate. This allows the merchant to settle for more than the quoted `tradeRate`.", - "type": { - "kind": "SCALAR", - "name": "Percentage", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ExchangeRateQuotePayload", - "description": "Exchange rate quotes for a specific customer.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "quotes", - "description": "Exchange rate quote details for each base and quote currency combination.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "ExchangeRateQuote", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ExternalVaultStatus", - "description": "A credit card's assocation with an external vault.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "VAULTED", - "description": "The payment method for this transaction has been vaulted in an external vault.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WILL_VAULT", - "description": "The payment method has not been vaulted in an exernal vault, but it will be if this transaction is successfully processed.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "FacilitatorDetails", - "description": "Fields capturing information about a third party that provided payment information for this transaction via the Grant API, Shared Vault, or Google Pay.", - "fields": [ - { - "name": "oauthApplication", - "description": "The OAuth application that owns the payment information used to create the transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "OAuthApplication", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "FailedEvent", - "description": "Accompanying information for a transaction that failed because it could not be successfully sent to the processor.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction failed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response, or an explanation for the lack thereof.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionAuthorizationProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "networkResponse", - "description": "Fields describing the network response to the authorization request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentNetworkResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "riskDecision", - "description": "Risk decision for this transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "RiskDecision", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "FinalizeDisputeInput", - "description": "Top-level input fields for finalizing a dispute.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disputeId", - "description": "The ID of the dispute to be finalized.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "FinalizeDisputePayload", - "description": "Top-level field returned when finalizing a dispute.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "dispute", - "description": "Information about the dispute that was finalized.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Dispute", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Float", - "description": "Built-in Float", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "FraudProviderConfiguration", - "description": "Configuration for fraud protection provider.", - "fields": [ - { - "name": "merchantId", - "description": "The merchant ID used by the fraud protection provider to identify the fraud data collection request.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "The name of the fraud provider.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "FraudServiceProvider", - "description": "The fraud service provider used to generate the risk decision.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CHARGEBACK_PROTECTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "EFFORTLESS_CHARGEBACK_PROTECTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAUD_PROTECTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAUD_PROTECTION_ADVANCED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAUD_PROTECTION_ENTERPRISE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "KOUNT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "GatewayRejectedEvent", - "description": "Accompanying information for a gateway rejected transaction.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction was rejected by the gateway.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "gatewayRejectionReason", - "description": "The reason the transaction was rejected, based on your gateway settings.", - "args": [], - "type": { - "kind": "ENUM", - "name": "GatewayRejectionReason", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response. Depending on your gateway settings, the AVS and CVV responses may be the reason for the rejection.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionAuthorizationProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "networkResponse", - "description": "Fields describing the network response to the authorization request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentNetworkResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "riskDecision", - "description": "Risk decision for this transaction. If the gatewayRejectionReason is fraud, this may be the reason for the rejection.", - "args": [], - "type": { - "kind": "ENUM", - "name": "RiskDecision", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "duplicateOf", - "description": "The original transaction if the gateway rejection reason was `DUPLICATE`.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "GatewayRejectionReason", - "description": "Possible reasons why a transaction was rejected by the gateway.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "APPLICATION_INCOMPLETE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AVS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AVS_AND_CVV", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CVV", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DUPLICATE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "EXCESSIVE_RETRY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAUD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MANUAL_TRANSACTIONS_DISABLED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYMENT_METHOD_BLOCKED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RISK_THRESHOLD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "THREE_D_SECURE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TOKEN_ISSUANCE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TOO_MANY_CONFIRMATION_ATTEMPTS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNION_PAY_ENROLLMENT_REQUIRED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "GenerateExchangeRateQuoteInput", - "description": "Input to generate a list of exchange rate quotes.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "quotes", - "description": "Base and quote currency combinations for which the quote will be generated.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ExchangeRateQuoteInput", - "ofType": null - } - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "GeoCoordinates", - "description": "Coordinates describing a geographic position.", - "fields": [ - { - "name": "latitude", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Float", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "longitude", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Float", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "GeoCoordinatesInput", - "description": "Coordinates describing a geographic position.", - "fields": null, - "inputFields": [ - { - "name": "latitude", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Float", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "longitude", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Float", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "GooglePayConfiguration", - "description": "Configuration for Google Pay on Android and the web.", - "fields": [ - { - "name": "countryCode", - "description": "The country code of the acquiring bank where the transaction is likely to be processed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CountryCodeAlpha2", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "displayName", - "description": "A string used to identify the merchant to the customer.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "environment", - "description": "The environment being used for Google Pay.", - "args": [], - "type": { - "kind": "ENUM", - "name": "GooglePayEnvironment", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "googleAuthorization", - "description": "Authorization to use when tokenizing a Google Pay payment method.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is included for supporting legacy clients." - }, - { - "name": "paypalClientId", - "description": "A string used to identify the merchant's PayPal account when generating a PayPal Closed Loop Token.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "supportedCardBrands", - "description": "A list of card brands supported by the merchant for Google Pay.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "GooglePayEnvironment", - "description": "The environment being used for Google Pay.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "PRODUCTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SANDBOX", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "production", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sandbox", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "GooglePayOriginDetails", - "description": "Additional information about the payment method specific to Google Pay.", - "fields": [ - { - "name": "googleTransactionId", - "description": "A reference ID for the Google transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "HyperwalletAccountDetails", - "description": "Details about a Hyperwallet account.", - "fields": [ - { - "name": "userId", - "description": "The ID of the Hyperwallet account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "ID", - "description": null, - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "IDealConfiguration", - "description": "Configuration for iDEAL.", - "fields": [ - { - "name": "routeId", - "description": "The route ID used to process an iDEAL payment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "assetsUrl", - "description": "A URL used to redirect the customer to the bank's web page.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreContext", - "description": "Reference object for an in-store request.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this in-store request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": true, - "deprecationReason": "Use the id field from the InStoreContextPayload" - }, - { - "name": "transaction", - "description": "The transaction representing the charge on the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use a Node query for a RequestTransactionInStoreContext" - }, - { - "name": "refund", - "description": "The refund representing the refund on the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use a Node query for a RequestRefundInStoreContext" - }, - { - "name": "reader", - "description": "The reader associated with the in-store request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use the reader field from the InStoreContextPayload" - }, - { - "name": "status", - "description": "The status of the context created.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": true, - "deprecationReason": "Use the status field from the InStoreContextPayload" - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "description": "Top-level fields returned when requesting a state change on an in-store reader.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inStoreContext", - "description": "The in-store context created when an in-store flow is initiated.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreContext", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use top-level fields" - }, - { - "name": "id", - "description": "A unique ID for this in-store context request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader associated with the in-store request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "description": "Reference object for an in-store request.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this in-store request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader associated with the in-store request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "InStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestChargeInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestConfirmationPromptInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestDisplayInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestFirmwareUpdateInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestRefundInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestSignaturePromptInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestVaultInStoreContext", - "ofType": null - } - ] - }, - { - "kind": "ENUM", - "name": "InStoreContextStatus", - "description": "Potential statuses of a context created as part of an in-store request.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CANCELLED", - "description": "The context was successfully canceled.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "COMPLETE", - "description": "Successful. The context was ended.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FAILED", - "description": "Not successful. The context was ended.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PENDING", - "description": "Flow in-progress. Waiting for reader or point of sale interaction.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROCESSING", - "description": "Payment flow in-progress. Customer payment method submitted for transaction processing.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreDisplayItemInput", - "description": "Input fields for an individual display item on an in-store reader.", - "fields": null, - "inputFields": [ - { - "name": "kind", - "description": "The display item type to be displayed on the in-store reader.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "DisplayItemType", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "description", - "description": "The display item text to be displayed on the in-store reader. 35 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "quantity", - "description": "The number of units for a CHARGE or DISCOUNT item. Must be greater than 0.", - "type": { - "kind": "SCALAR", - "name": "Float", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The total amount of a CHARGE or DISCOUNT item.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreLocation", - "description": "An in-store location.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "Name of the in-store location.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "internalName", - "description": "A merchant-assigned internal name of this location, unique to this merchant.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "address", - "description": "The address of the in-store location.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreLocationAddress", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "geoCoordinates", - "description": "The coordinates of this location.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "GeoCoordinates", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payerId", - "description": "The PayPal account ID to which this location was added.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "qrCodePaymentsEnabled", - "description": "Whether QR code payments will be enabled for this location.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreLocationAddress", - "description": "Input fields for an in-store location address.", - "fields": [ - { - "name": "streetAddress", - "description": "The street address.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "extendedAddress", - "description": "Extended address information, such as an apartment or suite number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "locality", - "description": "Locality/city.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "region", - "description": "State or province.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "postalCode", - "description": "Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "countryCode", - "description": "Country code for the address.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationAddressInput", - "description": "Input fields for an in-store Location Address.", - "fields": null, - "inputFields": [ - { - "name": "streetAddress", - "description": "The street address.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "extendedAddress", - "description": "Extended address information, such as an apartment or suite number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locality", - "description": "Locality/city.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "region", - "description": "State or province.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "postalCode", - "description": "Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "Country code for the address.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationAddressUpdateInput", - "description": "Input fields for an in-store Location Address update.", - "fields": null, - "inputFields": [ - { - "name": "streetAddress", - "description": "The street address.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "extendedAddress", - "description": "Extended address information, such as an apartment or suite number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locality", - "description": "Locality/city.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "region", - "description": "State or province.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "postalCode", - "description": "Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "Country code for the address.", - "type": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreLocationConnection", - "description": "A paginated list of in-store locations.", - "fields": [ - { - "name": "edges", - "description": "A list of in-store locations.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "InStoreLocationConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of in-store locations contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreLocationConnectionEdge", - "description": "An in-store location within an InStoreLocationConnection.", - "fields": [ - { - "name": "cursor", - "description": "The in-store locations's location within the InStoreLocationConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The in-store location.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreLocation", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationInput", - "description": "Fields required for an instore location.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "The publicly visible label of this Location.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "internalName", - "description": "Name assigned by the merchant to uniquely identify this Location.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "address", - "description": "The address of the in-store Location.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationAddressInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "geoCoordinates", - "description": "The coordinates of this location.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "GeoCoordinatesInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "payerId", - "description": "The PayPal account ID to which this Location will be added.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "enableQRCodePayments", - "description": "Whether QR code payments will be enabled for this location.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationUpdateInput", - "description": "Fields required to update an in-store location.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "The publicly visible label of this location.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "internalName", - "description": "Name assigned by the merchant to uniquely identify this location.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "address", - "description": "The address of the location.", - "type": { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationAddressUpdateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "geoCoordinates", - "description": "The coordinates of this location.", - "type": { - "kind": "INPUT_OBJECT", - "name": "GeoCoordinatesInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "payerId", - "description": "The PayPal account ID to which this location will be added.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "enableQRCodePayments", - "description": "Whether QR code payments will be enabled for this location.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreReader", - "description": "An in-store payment card reader.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "Name given to the reader.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "vendor", - "description": "Vendor-specific information about the reader.", - "args": [], - "type": { - "kind": "UNION", - "name": "InStoreReaderVendor", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "location", - "description": "The in-store location the reader is attached to.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreLocation", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "Current status of the reader.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ReaderStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pairedAt", - "description": "Date and time when the reader was paired.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "lastSeenAt", - "description": "Date and time when the reader last established a connection.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "offlineSince", - "description": "Date and time when the reader last disconnected.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "softwareVersion", - "description": "The version of the payment application running on the Reader.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "InStoreReaderAuthorizationMode", - "description": "The authorization mode used to perform the transaction.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CARD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ISSUER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreReaderConnection", - "description": "A paginated list of in-store readers.", - "fields": [ - { - "name": "edges", - "description": "A list of in-store readers.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "InStoreReaderConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of in-store readers contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InStoreReaderConnectionEdge", - "description": "An in-store reader within an InStoreReaderConnection.", - "fields": [ - { - "name": "cursor", - "description": "The in-store reader's location within the InStoreReaderConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The in-store reader.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INTERFACE", - "name": "InStoreReaderOriginDetails", - "description": "Additional information about the payment method supplied by an in-store payment reader.", - "fields": [ - { - "name": "authorizationMode", - "description": "The authorization mode used to perform the transaction on the payment reader.", - "args": [], - "type": { - "kind": "ENUM", - "name": "InStoreReaderAuthorizationMode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pinVerified", - "description": "An indicator for whether the transaction was verified via pin.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inputMode", - "description": "The input mode used on the payment reader to facilitate an in-store transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentReaderInputMode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminalId", - "description": "The ID of the terminal that was processed this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "CardPresentOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "EmvCardOriginDetails", - "ofType": null - } - ] - }, - { - "kind": "OBJECT", - "name": "InStoreReaderPayload", - "description": "Top-level fields returned for an in-store reader.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreReaderSearchInput", - "description": "Input fields for searching for in-store readers.", - "fields": null, - "inputFields": [ - { - "name": "locationId", - "description": "Find in-store readers with location ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "softwareVersion", - "description": "Find in-store readers with software version.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchSoftwareVersionInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerStatus", - "description": "Find in-store readers with reader status.", - "type": { - "kind": "ENUM", - "name": "ReaderStatus", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreReaderSetupInput", - "description": "Fields that are reader specific for pairing a reader.", - "fields": null, - "inputFields": [ - { - "name": "locationId", - "description": "In-Store Location to attach Reader to.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "name", - "description": "Name given to the Reader.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "InStoreReaderVendor", - "description": "A union of all possible in-store reader vendors.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "VerifoneVendor", - "ofType": null - } - ] - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreRefundInput", - "description": "Input fields for creating an in-store transaction.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customFields", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomFieldInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "descriptor", - "description": "Fields used to define what will appear on a customer's bank statement for a specific purchase.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionDescriptorInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "InStoreTransactionInput", - "description": "Input fields for creating an in-store transaction.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customFields", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomFieldInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "descriptor", - "description": "Fields used to define what will appear on a customer's bank statement for a specific purchase.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionDescriptorInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "vaultPaymentMethodAfterTransacting", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "VaultInStorePaymentMethodAfterTransactingInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "channel", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Int", - "description": "Built-in Int", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "KountConfiguration", - "description": "Configuration for Kount fraud tools.", - "fields": [ - { - "name": "merchantId", - "description": "The Kount merchant ID used to identify the fraud data collection request.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Language", - "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: ^[a-z]{2}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}))?$", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "LegacyIdType", - "description": "The type of object the legacy ID represents when converting it to a global ID.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CUSTOMER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DISPUTE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MERCHANT_ACCOUNT_APPLICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYMENT_CONTEXT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYMENT_METHOD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REFUND", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRANSACTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "US_BANK_ACCOUNT_VERIFICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VERIFICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "LiabilityShift", - "description": "A scenario detailing which party assumes liability for certain conditions in the event of a transaction being disputed.", - "fields": [ - { - "name": "responsibleParty", - "description": "The party taking responsibility for liability.", - "args": [], - "type": { - "kind": "ENUM", - "name": "LiabilityShiftResponsibleParty", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "conditions", - "description": "The specific conditions under which the responsible party assumes liability, in the event of a chargeback.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "LiabilityShiftCondition", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "LiabilityShiftCondition", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ITEM_NOT_RECEIVED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNAUTHORIZED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "LiabilityShiftResponsibleParty", - "description": "If enrolled in Effortless Chargeback Protection, and in the event the transaction is disputed, these are the possible parties which can assume liability.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ISSUER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "LocalPaymentAddressInput", - "description": "Input fields for local payment addresses.", - "fields": null, - "inputFields": [ - { - "name": "streetAddress", - "description": "The street address.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "extendedAddress", - "description": "Extended address information, such as an apartment or suite number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locality", - "description": "Locality/city.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "region", - "description": "State or province.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "postalCode", - "description": "Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "Country code for the address.", - "type": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "LocalPaymentContext", - "description": "The LocalPayment object.", - "fields": [ - { - "name": "id", - "description": "Unique identifier for the payment context.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "The type of the local payment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "LocalPaymentMethodType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "approvalUrl", - "description": "The URL to which a customer should be redirected to approve the local payment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount charged in this local payment.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAccountId", - "description": "The merchant account used to create the payment context.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactedAt", - "description": "Date and time when the local payment context was used to create a transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "approvedAt", - "description": "Date and time when the local payment context was approved by the customer.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the local payment context was created.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updatedAt", - "description": "Date and time when the local payment context was updated.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "expiredAt", - "description": "Date and time when the local payment context was expired.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentId", - "description": "Unique identifier for the local payment.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "orderId", - "description": "The PayPal Invoice ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "PaymentContext", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "LocalPaymentDetails", - "description": "Local payment specific details on a transaction.", - "fields": [ - { - "name": "origin", - "description": "Additional information about the local payment method provided from a third-party origin, such as PayPal or another regional payment method provider.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethodOrigin", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "Regional payment method selected by the customer.", - "args": [], - "type": { - "kind": "ENUM", - "name": "LocalPaymentMethodType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "displayName", - "description": "Description of the payment method that can be displayed to customers.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "LocalPaymentMethodType", - "description": "A value identifying the type of regional payment method.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ALIPAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "BANCONTACT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "BLIK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "BOLETOBANCARIO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "EPS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GIROPAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GRABPAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IDEAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MULTIBANCO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MYBANK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OXXO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "P24", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYU", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAY_UPON_INVOICE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SATISPAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SEPA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SOFORT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SWISH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRUSTLY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VERKKOPANKKI", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VIPPS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WECHAT_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "LocalPaymentPayerInfoInput", - "description": "Input fields for the payer of a local payment.", - "fields": null, - "inputFields": [ - { - "name": "givenName", - "description": "The payer's given (first) name.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "surname", - "description": "The payer's surname (last name).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "email", - "description": "The payer's email.", - "type": { - "kind": "SCALAR", - "name": "EmailAddress", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "The payer's phone number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingAddress", - "description": "The payer's shipping address.", - "type": { - "kind": "INPUT_OBJECT", - "name": "LocalPaymentAddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "description": "The payer's billing address.", - "type": { - "kind": "INPUT_OBJECT", - "name": "LocalPaymentAddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "taxInfo", - "description": "The payer's tax information. This is only required for Boleto Bancário payments.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TaxInfoInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "MVVAcceptanceChannel", - "description": "Means by which customers by their bills.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "FACE_TO_FACE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MAIL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PHONE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WEB", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "MVVRegistrationType", - "description": "Supported MVV (Merchant Verification Value) programs.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "LOAN_VPP", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TAX_DEBIT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UTIL_RATE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UTIL_VPP", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "MVVUtilityType", - "description": "Supported MVV (Merchant Verification Value) utility types.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ELECTRIC", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GAS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRASH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WATER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "MandateType", - "description": "Mandate type for SEPA Direct Debit Account.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ONE_OFF", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MasterpassConfiguration", - "description": "Configuration for Masterpass.", - "fields": [ - { - "name": "merchantCheckoutId", - "description": "The Masterpass merchant checkout ID used to identify the merchant in Masterpass requests.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "supportedCardBrands", - "description": "A list of card brands supported by the merchant for Masterpass.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MasterpassOriginDetails", - "description": "Additional information about the payment method specific to Masterpass.", - "fields": [ - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Merchant", - "description": "Details about a merchant and its current settings.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "Current status.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "companyName", - "description": "Company name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "website", - "description": "The merchant's main website.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timezone", - "description": "The timezone that the merchant operates in.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAccounts", - "description": "A paginated list of merchant accounts that belong to this merchant. Filtered by search criteria, if provided.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "INPUT_OBJECT", - "name": "MerchantAccountSearchInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "MerchantAccountConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MerchantAccount", - "description": "Information about a merchant account associated with a merchant.", - "fields": [ - { - "name": "id", - "description": "Unique identifier for the merchant account. Used to determine what merchant account processed or will process a given Payment.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bankAccount", - "description": "The disbursement bank account linked with the merchant account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "DisbursementBankAccount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "currencyCode", - "description": "The ISO code for the currency the merchant account uses.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "dbaName", - "description": "Business name of the account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "externalId", - "description": "A unique identifier for this account in external systems.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of a merchant account. This determines whether the merchant account can be used to create a Payment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "MerchantAccountStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDefault", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paypalAccount", - "description": "The PayPal account linked with the merchant account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PayPalAccountDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hyperwalletAccount", - "description": "The Hyperwallet account linked with the merchant account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "HyperwalletAccountDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "venmoAccount", - "description": "The Venmo account linked with the merchant account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "VenmoAccountDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "threeDSecure", - "description": "The 3D Secure configuration for the merchant account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MerchantAccountThreeDSecureConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MerchantAccountApplication", - "description": "A record of a merchant account application.", - "fields": [ - { - "name": "id", - "description": "A unique ID for the account application. Can be used to query the status of the onboarding request in the future.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique ID.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the application.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ApplicationStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MerchantAccountConnection", - "description": "A paginated list of merchant accounts.", - "fields": [ - { - "name": "edges", - "description": "A list of merchant accounts.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "MerchantAccountConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of merchant accounts contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MerchantAccountConnectionEdge", - "description": "A merchant account within a MerchantAccountConnection.", - "fields": [ - { - "name": "cursor", - "description": "This merchant account's location within the MerchantAccountConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The merchant account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MerchantAccount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "MerchantAccountSearchInput", - "description": "Input fields for searching for merchant accounts.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find merchant accounts with an id or ids.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paypalAccountId", - "description": "Find merchant accounts associated with a given PayPal account ID.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "MerchantAccountStatus", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ACTIVE", - "description": "The merchant account can be used to create transactions and refunds.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PENDING", - "description": "The merchant account is still being set up, and cannot be used to create transactions or refunds yet.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUSPENDED", - "description": "The merchant account cannot be used to process transactions or refunds.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MerchantAccountThreeDSecureConfiguration", - "description": "Details about the 3D Secure configuration of the merchant account.", - "fields": [ - { - "name": "v1", - "description": "Configuration for 3D Secure v1.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MerchantAccountThreeDSecureVersionConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "v2", - "description": "Configuration for 3D Secure v2.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MerchantAccountThreeDSecureVersionConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MerchantAccountThreeDSecureVersionConfiguration", - "description": "Details about the configuration of a version of 3D Secure for the merchant account.", - "fields": [ - { - "name": "supportedCardBrands", - "description": "Card types enabled for this 3D Secure version.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "MonetaryAmount", - "description": "A monetary amount with currency.", - "fields": [ - { - "name": "value", - "description": "The amount of money, either a whole number or a number with up to 3 decimal places.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "currencyIsoCode", - "description": "The ISO code for the money's currency.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `currencyCode` instead." - }, - { - "name": "currencyCode", - "description": "The currency code for the monetary amount.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountInput", - "description": "Input fields representing an amount with currency.", - "fields": null, - "inputFields": [ - { - "name": "value", - "description": "The amount of money, either a whole number or a number with up to 3 decimal places.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "currencyCode", - "description": "The currency code for the monetary amount.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountSearchInput", - "description": "Input fields for searching for a transaction or refund amount.", - "fields": null, - "inputFields": [ - { - "name": "value", - "description": "Find transactions for a given amount.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchRangeInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "currencyIsoCode", - "description": "Deprecated: Please use `currencyCode` instead.\n\nFind transactions with a given currency.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "currencyCode", - "description": "Find transactions with a given currency.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Month", - "description": "A two-digit, zero-padded month.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Mutation", - "description": "The top-level Mutation type. Mutations are used to make requests that create or modify data.", - "fields": [ - { - "name": "authorizePaymentMethod", - "description": "Authorize an eligible payment method and return a payload that includes details of the resulting transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AuthorizePaymentMethodInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authorizePayPalAccount", - "description": "Authorize an eligible PayPal account and return a payload that includes details of the resulting transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AuthorizePayPalAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PayPalTransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authorizeVenmoAccount", - "description": "Authorize an eligible Venmo account and return a payload that includes details of the resulting transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AuthorizeVenmoAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authorizeCreditCard", - "description": "Authorize a credit card of any origin and return a payload that includes details of the resulting transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AuthorizeCreditCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "captureTransaction", - "description": "Capture an authorized transaction and return a payload that includes details of the transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CaptureTransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "chargePaymentMethod", - "description": "Charge any payment method and return a payload that includes details of the resulting transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ChargePaymentMethodInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "chargeUsBankAccount", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ChargeUsBankAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "chargePayPalAccount", - "description": "Charge a PayPal account and return a payload that includes details of the resulting transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ChargePayPalAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PayPalTransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "chargeVenmoAccount", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ChargeVenmoAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "chargeCreditCard", - "description": "Charge a credit card of any origin and return a payload that includes details of the resulting transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ChargeCreditCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "vaultPaymentMethod", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VaultPaymentMethodInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VaultPaymentMethodPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "vaultUsBankAccount", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VaultUsBankAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VaultPaymentMethodPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "vaultCreditCard", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VaultCreditCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VaultPaymentMethodPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundTransaction", - "description": "Refund a settled transaction and return a payload that includes details of the refund.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RefundTransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "RefundTransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reverseTransaction", - "description": "Reverse a transaction and return a payload that includes either the voided transaction or a refund.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ReverseTransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "ReverseTransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reverseRefund", - "description": "Reverse a refund and return a payload that includes voided refund.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ReverseRefundInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "RefundTransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundCreditCard", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RefundCreditCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "RefundCreditCardPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundUsBankAccount", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RefundUsBankAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "RefundUsBankAccountPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updateTransactionCustomFields", - "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).", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UpdateTransactionCustomFieldsInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "UpdateTransactionCustomFieldsPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verifyPaymentMethod", - "description": "Run a verification on a multi-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VerifyPaymentMethodInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VerifyPaymentMethodPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verifyCreditCard", - "description": "Run a verification on a multi-use credit card payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VerifyCreditCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VerifyPaymentMethodPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verifyUsBankAccount", - "description": "Run a verification on a multi-use US bank account payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VerifyUsBankAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VerifyPaymentMethodPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "confirmMicroTransferAmounts", - "description": "Confirm micro-transfer amounts initiated by vaultUsBankAccount or verifyUsBankAccount, completing the verification process for a US Bank Account via micro-transfer.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "ConfirmMicroTransferAmountsInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "ConfirmMicroTransferAmountsPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deletePaymentMethodFromVault", - "description": "Delete a multi-use payment method from the vault.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DeletePaymentMethodFromVaultInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "DeletePaymentMethodFromVaultPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createClientToken", - "description": "Create a client token that can be used to initialize a client in order to tokenize payment information.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "INPUT_OBJECT", - "name": "CreateClientTokenInput", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreateClientTokenPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createUniversalAccessToken", - "description": "Create a PayPal access token that can be used to make additional API calls or initialize a client.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreateUniversalAccessTokenInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreateUniversalAccessTokenPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "partialCaptureTransaction", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PartialCaptureTransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PartialCaptureTransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizeCustomActionsPaymentMethod", - "description": "Tokenize Custom Actions fields and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizeCustomActionsPaymentMethodInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizeCustomActionsPaymentMethodPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizeCreditCard", - "description": "Tokenize credit card fields and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizeCreditCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizeCreditCardPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizeCvv", - "description": "Tokenize a credit card's CVV and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizeCvvInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizeCvvPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizeNetworkToken", - "description": "Tokenize a network tokenized payment instrument and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizeNetworkTokenInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizeNetworkTokenPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizeSamsungPayCard", - "description": "Tokenize Samsung Pay card fields and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizeSamsungPayCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizeSamsungPayCardPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizeUsBankAccount", - "description": "Tokenize US bank account fields and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizeUsBankAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizeUsBankAccountPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizeUsBankLogin", - "description": "Tokenize US bank login fields and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizeUsBankLoginInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizeUsBankAccountPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizePayPalOneTimePayment", - "description": "Tokenize PayPal One-Time Payment and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizePayPalOneTimePaymentInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizePayPalOneTimePaymentPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createPayPalOneTimePayment", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreatePayPalOneTimePaymentInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreatePayPalOneTimePaymentPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizePayPalBillingAgreement", - "description": "Tokenize PayPal account and return a payload that includes a single-use payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TokenizePayPalBillingAgreementInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TokenizePayPalBillingAgreementPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createPayPalBillingAgreement", - "description": "Set up a PayPal Billing Agreement Token for approval by a PayPal user.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreatePayPalBillingAgreementInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreatePayPalBillingAgreementPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createCustomer", - "description": "Create a customer for storing individual customer information and/or grouping transactions and multi-use payment methods.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "INPUT_OBJECT", - "name": "CreateCustomerInput", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreateCustomerPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updateCustomer", - "description": "Update a customer's information.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UpdateCustomerInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "UpdateCustomerPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deleteCustomer", - "description": "Delete a customer, breaking association between any of the customer's transactions. Will not delete if the customer has existing payment methods.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DeleteCustomerInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "DeleteCustomerPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deletePaymentMethodFromSingleUseToken", - "description": "Delete a payment method referenced by a single-use token.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DeletePaymentMethodFromSingleUseTokenInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "DeletePaymentMethodFromSingleUseTokenPayload", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `deletePaymentMethodFromVault` instead." - }, - { - "name": "updateCreditCardBillingAddress", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UpdateCreditCardBillingAddressInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "UpdateCreditCardBillingAddressPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "performThreeDSecureLookup", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PerformThreeDSecureLookupInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PerformThreeDSecureLookupPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "acceptDispute", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AcceptDisputeInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "AcceptDisputePayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "finalizeDispute", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "FinalizeDisputeInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "FinalizeDisputePayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createDisputeTextEvidence", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreateDisputeTextEvidenceInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreateDisputeTextEvidencePayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deleteDisputeEvidence", - "description": "Deletes evidence from a dispute.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DeleteDisputeEvidenceInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "DeleteDisputeEvidencePayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createDisputeFileEvidence", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreateDisputeFileEvidenceInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreateDisputeFileEvidencePayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "vaultPayPalBillingAgreement", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VaultPayPalBillingAgreementInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VaultPayPalBillingAgreementPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sandboxSettleTransaction", - "description": "Force a transaction to settle in the sandbox environment. Generates an error elsewhere.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "SandboxSettleTransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createInStoreLocation", - "description": "Creates a new In-Store Location to associate Readers.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreateInStoreLocationInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreateInStoreLocationPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updateInStoreLocation", - "description": "Updates an In-Store Location.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UpdateInStoreLocationInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "UpdateInStoreLocationPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pairInStoreReader", - "description": "Pairs a Reader to an account and In-Store Location.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PairInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreReaderPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updateInStoreReader", - "description": "Updates an In-Store Reader.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UpdateInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreReaderPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestChargeFromInStoreReader", - "description": "Request an in-store reader to begin the charge flow.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestChargeFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestCancelFromInStoreReader", - "description": "Request an in-store reader to cancel the charge flow.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestCancelFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestRefundFromInStoreReader", - "description": "Request an in-store reader to start an unreferenced refund flow.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestRefundFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestVaultFromInStoreReader", - "description": "Request an in-store reader to vault a payment method.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestVaultFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestTextDisplayFromInStoreReader", - "description": "Request an in-store reader to display text.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestTextDisplayFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestItemDisplayFromInStoreReader", - "description": "Request an in-store reader to display line items.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestItemDisplayFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestFirmwareUpdateFromInStoreReader", - "description": "Request an in-store reader to update to the latest version of software.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestFirmwareUpdateFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestSignaturePromptFromInStoreReader", - "description": "Request an in-store reader to display a signature prompt.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestSignaturePromptFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "requestConfirmationPromptFromInStoreReader", - "description": "Request an in-store reader to display a confirmation prompt.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RequestConfirmationPromptFromInStoreReaderInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updateTransactionAmount", - "description": "Updates the authorization amount of the transaction.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UpdateTransactionAmountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "generateExchangeRateQuote", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "GenerateExchangeRateQuoteInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "ExchangeRateQuotePayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createNonInstantLocalPaymentContext", - "description": "Creates a non-instant local payment context. Your account must be enabled to use this feature.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreateNonInstantLocalPaymentContextInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CreateNonInstantLocalPaymentContextPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "NameInput", - "description": "The name of the party.", - "fields": null, - "inputFields": [ - { - "name": "prefix", - "description": "The prefix, or title, to the party name.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "givenName", - "description": "The party's given, or first, name. Required if the party is a person.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "surname", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "middleName", - "description": "The party's middle name. Use also to store multiple middle names including the patronymic, or father's, middle name.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "suffix", - "description": "The suffix for the party's name.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "alternateFullName", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "NetworkTokenInput", - "description": "Input fields for a network tokenized card.", - "fields": null, - "inputFields": [ - { - "name": "cryptogram", - "description": "A one-time-use string generated by the token requester to validate the transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "eCommerceIndicator", - "description": "A two-digit string that should be passed along in the authorization message.", - "type": { - "kind": "SCALAR", - "name": "ECommerceIndicator", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "expirationMonth", - "description": "A two-digit string representing the expiration month of the DPAN.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Month", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "expirationYear", - "description": "A four-digit string representing the expiration year of the DPAN.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Year", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "number", - "description": "The card number used in processing. This is a device PAN (DPAN), not the backing card number (FPAN).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CreditCardNumber", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "originDetails", - "description": "Additional information about a network token.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "NetworkTokenOriginDetailsInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "NetworkTokenOrigin", - "description": "The source of the network token.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "APPLE_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GOOGLE_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NETWORK_TOKEN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "NetworkTokenOriginDetails", - "description": "Additional information about the payment method specific to Network Token.", - "fields": [ - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "NetworkTokenOriginDetailsInput", - "description": "Information about the network token, such as the origin of the network token, source card details, and other token requestor data.", - "fields": null, - "inputFields": [ - { - "name": "origin", - "description": "The origin of the network token.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "NetworkTokenOrigin", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "sourceCardDescription", - "description": "A string, suitable for display, that describes the backing card.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "sourceCardLast4", - "description": "The last 4 digits of the backing card number (FPAN).", - "type": { - "kind": "SCALAR", - "name": "CreditCardLast4", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "sourceCardType", - "description": "The card type of the backing card.", - "type": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "tokenRequestorId", - "description": "The token requestor ID of the entity that generated this network token.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "The transaction ID for this network token.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INTERFACE", - "name": "Node", - "description": "Relay compatible Node interface.", - "fields": [ - { - "name": "id", - "description": "Global ID for a given object.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "BusinessAccountCreationRequest", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "CustomActionsPaymentContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Customer", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Dispute", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "InStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "LocalPaymentContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestChargeInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestConfirmationPromptInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestDisplayInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestFirmwareUpdateInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestRefundInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestSignaturePromptInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "RequestVaultInStoreContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Verification", - "ofType": null - } - ] - }, - { - "kind": "INPUT_OBJECT", - "name": "NonInstantLocalPaymentContextInput", - "description": "Input fields for non-instant local payment context.", - "fields": null, - "inputFields": [ - { - "name": "orderId", - "description": "The order id of the eventual Braintree transaction and the invoice number of the local payment context. Maximum 127 characters.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The amount of the local payment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "type", - "description": "The type of the non-instant local payment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "NonInstantLocalPaymentMethodType", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "countryCode", - "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.", - "type": { - "kind": "SCALAR", - "name": "CountryCode", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locale", - "description": "The language tag for the language in which to localize the error-related strings.", - "type": { - "kind": "SCALAR", - "name": "Language", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "returnUrl", - "description": "The URL where the customer is redirected after the customer approves the payment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "cancelUrl", - "description": "The URL where the customer is redirected after the customer cancels the payment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the PayPal merchant account that will be used when charging this payment method.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "payerInfo", - "description": "The payer's information.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "LocalPaymentPayerInfoInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "expiryDate", - "description": "Overrides the default date at which the local payment context will expire. MULTIBANCO is not overridable.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "NonInstantLocalPaymentMethodType", - "description": "A value identifying the type of non-instant regional payment method.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BOLETOBANCARIO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MULTIBANCO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OXXO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "OAuthApplication", - "description": "Information about an OAuth Application.", - "fields": [ - { - "name": "clientId", - "description": "The unique identifier of the OAuth application.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "The name of the OAuth application.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "OAuthTokenType", - "description": "OAuth access token type.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BEARER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "OwnerAddressType", - "description": "The owner's address type.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "HOME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MAILING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "OwnerIDType", - "description": "The type of identity number provided for the owner.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "SOCIAL_SECURITY_NUMBER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "OwnerPhoneType", - "description": "The owner's phone type.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "HOME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MOBILE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "OwnerPosition", - "description": "The position that the owner holds in the business.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BENEFICIAL_OWNER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CHAIRMAN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DIRECTOR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PARTNER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SECRETARY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TREASURER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "OwnerRole", - "description": "The role that the owner holds in the business.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BENEFICIAL_OWNER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SIGNIFICANT_RESPONSIBILITY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PageInfo", - "description": "The page information for a connection.", - "fields": [ - { - "name": "hasNextPage", - "description": "Whether or not there is a next page available.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "hasPreviousPage", - "description": "Always false; backwards pagination is not supported. Present to comply with Relay specifications.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "startCursor", - "description": "The cursor for the first item in the connection page.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "endCursor", - "description": "The cursor for the last item in the connection page.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PairInStoreReaderInput", - "description": "Input fields for pairing an in store reader.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "userCode", - "description": "Code displayed on Reader during pairing.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "reader", - "description": "Inputs for Reader.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreReaderSetupInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ParentAuthorization", - "description": "An original authorization's relationship to all its partial capture transactions.", - "fields": [ - { - "name": "childCaptures", - "description": "The captures on a partially captured authorization.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "totalAmountAuthorized", - "description": "The total amount authorized by this transaction. This amount will not change as this transaction is partially captured.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "PartialCaptureDetails", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "ChildCapture", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "ParentAuthorization", - "ofType": null - } - ] - }, - { - "kind": "INPUT_OBJECT", - "name": "PartialCaptureTransactionInput", - "description": "Top-level input fields for capturing outstanding funds authorized by a transaction.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "ID of the original authorized transaction to be partially captured.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Input fields for the capture, with details that will define the resulting capture transaction.", - "type": { - "kind": "INPUT_OBJECT", - "name": "PartialCaptureTransactionOptionsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PartialCaptureTransactionOptionsInput", - "description": "Input fields for the capture, with details that will define the resulting capture transaction.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "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).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "discountAmount", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lineItems", - "description": "Line items for this transaction. Up to 249 line items may be specified.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionLineItemInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "orderId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "purchaseOrderNumber", - "description": "A purchase order identification value you associate with this transaction.\n\n*Required for Level 2 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shipping", - "description": "Shipping information.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionShippingInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "tax", - "description": "Tax information about the transaction.\n\n*Required for Level 2 processing*.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionTaxInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "descriptor", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionDescriptorInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PartialCaptureTransactionPayload", - "description": "Top-level output field from partially capturing a transaction.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "capture", - "description": "The transaction representing the partial capture.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalAccountDetails", - "description": "Details about a PayPal account.", - "fields": [ - { - "name": "billingAgreementId", - "description": "The ID of the billing agreement for this PayPal account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAddress", - "description": "The billing address associated with the PayPal account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "shippingAddress", - "description": "The shipping address associated with the PayPal account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "email", - "description": "The email address associated with the PayPal account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "phone", - "description": "The primary phone number associated with the PayPal account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payerId", - "description": "The PayPal ID of the PayPal account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "firstName", - "description": "The first name on the PayPal account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "lastName", - "description": "The last name on the PayPal account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cobrandedCardLabel", - "description": "The label of the co-branded card used as a funding source.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "origin", - "description": "Additional information if the PayPal account was provided from a third-party origin, such as Apple Pay, Google Pay, or another digital wallet.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethodOrigin", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "limitedUseOrderId", - "description": "Limited use PayPal provided Order ID (starts with O-).", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PayPalAccountInput", - "description": "Input for identifying a PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "payerId", - "description": "The unique PayPal ID of the PayPal account.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces" : null, - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind" : "ENUM", - "name" : "PayPalBillingAgreementChargePattern", - "description" : "Expected business/pricing model for a billing agreement (Charge Patterns).", - "fields" : null, - "inputFields" : null, - "interfaces" : null, - "enumValues" : [ - { - "name" : "DEFERRED", - "description" : "Pay after use, non-recurring post-paid, variable amount, irregular.", - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "IMMEDIATE", - "description" : "On-demand instant payments - non-recurring, pre-paid, variable amount.", - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "RECURRING_POSTPAID", - "description" : "Pay on a fixed date based on usage or consumption after the goods/service is delivered.", - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "RECURRING_PREPAID", - "description" : "Pay upfront fixed or variable amount on a fixed date before the goods/service is delivered.", - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "THRESHOLD_POSTPAID", - "description" : "Charge payer when the set amount is reached or monthly billing cycle, whichever comes first, after the goods/service is delivered.", - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "THRESHOLD_PREPAID", - "description" : "Charge payer when the set amount is reached or monthly billing cycle, whichever comes first, before the goods/service is delivered.", - "isDeprecated" : false, - "deprecationReason" : null - } - ], - "possibleTypes" : null - }, - { - "kind" : "INPUT_OBJECT", - "name" : "PayPalBillingAgreementExperienceProfileInput", - "description" : "Controls the experience in a PayPal billing agreement approval flow.", - "fields" : null, - "inputFields" : [ - { - "name" : "brandName", - "description" : "Merchant brand name to be displayed on the PayPal approval pages.", - "type" : { - "kind" : "SCALAR", - "name" : "String", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "collectShippingAddress", - "description" : "Indicates whether a shipping address will be collected from the customer during the agreement approval flow.", - "type" : { - "kind" : "SCALAR", - "name" : "Boolean", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "landingPageType", - "description" : "Specifies the PayPal page to display when a user lands on the PayPal site to complete the payment.", - "type" : { - "kind" : "ENUM", - "name" : "PayPalLandingPageType", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "locale", - "description" : "Locale of the PayPal payment approval experience.", - "type" : { - "kind" : "SCALAR", - "name" : "Language", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "shippingAddressEditable", - "description" : "Indicates whether to enable user editing of the shipping address. Only applies when shipping address is provided by merchant.", - "type" : { - "kind" : "SCALAR", - "name" : "Boolean", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces" : null, - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind" : "INPUT_OBJECT", - "name" : "PayPalBillingAgreementInput", - "description" : "Input fields for a PayPal account to be vaulted.", - "fields" : null, - "inputFields" : [ - { - "name" : "billingAgreementToken", - "description" : "The Billing Agreement token.", - "type" : { - "kind" : "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalConfiguration", - "description": "Configuration for PayPal.", - "fields": [ - { - "name": "displayName", - "description": "The merchant's company name for displaying to customers in the PayPal UI.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "clientId", - "description": "The merchant's PayPal client ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "privacyUrl", - "description": "The merchant's privacy policy URL.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "userAgreementUrl", - "description": "The merchant's user agreement URL.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "assetsUrl", - "description": "A URL pointing to the base path of Braintree's web pages used for various browser switches and popups.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "environment", - "description": "The PayPal environment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PayPalEnvironment", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "environmentNoNetwork", - "description": "For internal use only.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is only included for internal testing purposes." - }, - { - "name": "unvettedMerchant", - "description": "Whether or not the merchant has been vetted.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "braintreeClientId", - "description": "Braintree's PayPal client ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAgreementsEnabled", - "description": "Whether billing agreements are enabled and should be used instead of future payments.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAccountId", - "description": "The merchant account being used. This affects the currency code and other options.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "currencyCode", - "description": "The currency code to use.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payeeEmail", - "description": "The email address of the PayPal account that will receive the funds when a transaction is created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "directBaseUrl", - "description": "For internal use only.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field is only included for internal testing purposes." - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PayPalEnvironment", - "description": "The environment being used for PayPal.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CUSTOM", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LIVE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OFFLINE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "custom", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "live", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "offline", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PayPalExperienceProfileInput", - "description": "Controls the experience in a PayPal approval flow.", - "fields": null, - "inputFields": [ - { - "name": "brandName", - "description": "Merchant brand name to be displayed on the PayPal approval pages.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "collectShippingAddress", - "description": "Indicates whether a shipping address will be collected from the customer during the agreement approval flow.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "landingPageType", - "description": "Specifies the PayPal page to display when a user lands on the PayPal site to complete the payment.", - "type": { - "kind": "ENUM", - "name": "PayPalLandingPageType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locale", - "description": "Locale of the PayPal payment approval experience.", - "type": { - "kind": "SCALAR", - "name": "Language", - "ofType": null - }, - "defaultValue" : null - }, - { - "name" : "shippingAddressEditable", - "description" : "Indicates whether to enable user editing of the shipping address. Only applies when shipping address is provided by merchant.", - "type" : { - "kind" : "SCALAR", - "name" : "Boolean", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "userAction", - "description" : "Presents the customer with either the Continue or Pay Now (COMMIT) checkout flow. Default is Continue flow if the field is not provided.", - "type" : { - "kind" : "ENUM", - "name" : "PayPalUserAction", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PayPalFinancingCreditProductIdentifier", - "description": "Possible identifiers for credit products provided via PayPal.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CREDIT_CARD_INSTALLMENTS_BR", - "description": "Brazil Credit Card Installments.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_INSTALLMENTS_MX", - "description": "Mexico Credit Card Installments.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_US", - "description": "United States Credit Card.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYPAL_CREDIT_DE", - "description": "Germany PayPal Credit.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYPAL_CREDIT_UK", - "description": "United Kingdom PayPal Credit.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYPAL_CREDIT_US", - "description": "United States PayPal Credit.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAY_LATER_FR", - "description": "France Pay Later.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAY_LATER_GB", - "description": "Great Britain Pay Later.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAY_LATER_US", - "description": "United States Pay Later.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAY_UPON_INVOICE_DE", - "description": "Germany Pay Upon Invoice.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalFinancingOption", - "description": "PayPal financing options available for a transaction.", - "fields": [ - { - "name": "creditProductIdentifier", - "description": "The credit product identifier.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PayPalFinancingCreditProductIdentifier", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "qualifyingFinancingOptions", - "description": "Financing options the transaction qualifies for.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PayPalQualifyingFinancingOption", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PayPalFinancingOptionCreditType", - "description": "PayPal Financing option credit type.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "INSTALLMENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NO_INTEREST", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAY_UPON_INVOICE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SAME_AS_CASH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PayPalFinancingOptionsInput", - "description": "Input fields for requesting information about PayPal financing options.", - "fields": null, - "inputFields": [ - { - "name": "paymentMethodId", - "description": "ID of an existing multi-use PayPal payment method to request financing options for.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The transaction currency and total amount to finance.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "The financing country code.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CountryCodeAlpha2", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalFinancingOptionsPayload", - "description": "PayPal financing options response payload.", - "fields": [ - { - "name": "financingOptions", - "description": "PayPal financing options.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PayPalFinancingOption", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PayPalIntent", - "description": "The intent for PayPal payments.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AUTHORIZE", - "description": "Merchant will authorize the payment, but the funds will be captured separately.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ORDER", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SALE", - "description": "Merchant will authorize and captures funds simultaneously.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PayPalLandingPageType", - "description": "The type of landing page to display on the PayPal site for user checkout.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BILLING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DEFAULT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOGIN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PayPalLineItemInput", - "description": "Line items for a PayPal payment.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "Item name. Maximum 127 characters.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "quantity", - "description": "Number of units of the item purchased. This value can't be negative or zero.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "unitAmount", - "description": "Per-unit price of the item. Can include up to 2 decimal places. This value can't be negative or zero.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "type", - "description": "Indicates whether the line item is a debit (sale) or credit (refund or discount) to the customer.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "TransactionLineItemType", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "description", - "description": "Item description. Maximum 127 characters.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "productCode", - "description": "Product or UPC code for the item. Maximum 127 characters.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "unitTaxAmount", - "description": "Per-unit tax price of the item. Can include up to 2 decimal places. This value can't be negative or zero.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "url", - "description": "The URL to product information.", - "type": { - "kind": "SCALAR", - "name": "URL", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalLocalPaymentOriginDetails", - "description": "Additional information about the local payment method specific to PayPal.", - "fields": [ - { - "name": "captureId", - "description": "If funds for the transaction have settled, the PayPal ID for the capture of funds.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customField", - "description": "A string of field/value pairs passed directly to PayPal.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentId", - "description": "The identification value of the payment within PayPal's API.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactionFee", - "description": "The fee charged by PayPal for the transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalLocalPaymentRefundDetails", - "description": "PayPal local payment specific refund details.", - "fields": [ - { - "name": "refundId", - "description": "The PayPal refund ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundedFee", - "description": "Refunded transaction fee charged by PayPal.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PayPalOneTimePaymentInput", - "description": "Input fields for a PayPal account for a One-Time payment.", - "fields": null, - "inputFields": [ - { - "name": "payerId", - "description": "The PayPal payer ID.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "paymentId", - "description": "The PayPal payment ID. This ID is prefixed with \"PAYID-\".", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "paymentToken", - "description": "The PayPal payment token, also known as an Express Checkout token. This token is prefixed with \"EC-\".", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PayPalPayeeOptionsInput", - "description": "Input fields for a PayPal account receiving transaction funds.", - "fields": null, - "inputFields": [ - { - "name": "email", - "description": "The email address associated with the payee PayPal account.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces" : null, - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind" : "INPUT_OBJECT", - "name" : "PayPalProductAttributesInput", - "description" : "Product attributes input for PayPal billing agreement.", - "fields" : null, - "inputFields" : [ - { - "name" : "paypalBillingAgreementChargePattern", - "description" : "Expected business/pricing model for a billing agreement (Charge Patterns).", - "type" : { - "kind" : "ENUM", - "name" : "PayPalBillingAgreementChargePattern", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces" : null, - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind" : "OBJECT", - "name" : "PayPalQualifyingFinancingOption", - "description" : "PayPal qualifying financing options for a product.", - "fields" : [ - { - "name" : "apr", - "description" : "APR percentage.", - "args" : [], - "type" : { - "kind" : "SCALAR", - "name": "Percentage", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "nominalRate", - "description": "Nominal rate percentage.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Percentage", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "term", - "description": "Total number of payments over which to finance the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "intervalDuration", - "description": "The duration between each interval or payment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Duration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "countryCode", - "description": "The country or region for the financing option.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CountryCodeAlpha2", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "creditType", - "description": "Credit type.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PayPalFinancingOptionCreditType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "minimumAmount", - "description": "The minimum qualifying amount for a transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "monthlyInterestRate", - "description": "The monthly interest rate for this financing option.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Percentage", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "periodicPayment", - "description": "The amount for transaction periodic payments.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "monthlyPayment", - "description": "The amount for transaction monthly payments.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "discountAmount", - "description": "The discount amount on the transaction for this financing option.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "discountPercentage", - "description": "The discount percentage for this financing option.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Percentage", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "totalInterest", - "description": "The total interest cost for this financing option.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "totalCost", - "description": "The total amount for the transaction, including interest.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paypalSubsidized", - "description": "Indicates whether the financing option's credit fee is funded by PayPal.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalRefundDetails", - "description": "PayPal-specific refund details.", - "fields": [ - { - "name": "refundId", - "description": "The PayPal refund ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundedFee", - "description": "Refunded transaction fee charged by PayPal.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": "The description of this refund.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reason", - "description": "The reason this refund was created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PayPalRetailAppUsedForScanning", - "description": "The app used to scan an in-store QR code.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VENMO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PayPalShippingOptionInput", - "description": "A shipping option for a PayPal One-Time payment.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "description": "The cost for this shipping option.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "id", - "description": "A unique ID that identifies a shipping option.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "description", - "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ò`.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "selected", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "type", - "description": "The method by which the payer wants to receive their items.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PayPalShippingOptionType", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PayPalShippingOptionType", - "description": "The method by which the payer wants to receive their items.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "PICKUP", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SHIPPING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalTransactionDetails", - "description": "PayPal-specific details on a transaction.", - "fields": [ - { - "name": "authorizationId", - "description": "If the transaction was successfully authorized, the PayPal ID for the authorization.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "captureId", - "description": "If funds for the transaction have settled, the PayPal ID for the capture of funds.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customField", - "description": "A string of field/value pairs passed directly to PayPal.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payer", - "description": "Details about the payer or owner of the PayPal account.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PayPalAccountDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payee", - "description": "Details about the PayPal account that received the funds.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PayPalAccountDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payerStatus", - "description": "Whether or not the PayPal account has been verified by PayPal.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentId", - "description": "The identification value of the payment within PayPal's API.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundId", - "description": "If the transaction is a refund, the PayPal refund ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This field will never be populated as it only appears on refunds. Use `details.paypalId` on a refund instead." - }, - { - "name": "sellerProtectionStatus", - "description": "Whether or not the transaction qualifies for PayPal Seller Protection.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "taxId", - "description": "Payer's tax ID. Only returned for payments from Brazilian accounts.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "taxIdType", - "description": "Payer's tax ID type. Only returned for payments from Brazilian accounts. Allowed values BR_CPF or BR_CNPJ.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactionFee", - "description": "The fee charged by PayPal for the transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactionFeeAmount", - "description": "The fee charged by PayPal for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `transactionFee.value` instead." - }, - { - "name": "transactionFeeCurrencyIsoCode", - "description": "The currency code for the currency of the PayPal transaction fee.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `transactionFee.currencyCode` instead." - }, - { - "name": "description", - "description": "Description of the transaction that is displayed to customers in PayPal email receipts.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "origin", - "description": "Additional information if the credit card was provided from a third-party origin, such as Google Pay.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethodOrigin", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "selectedFinancingOption", - "description": "Buyer selected financing option at the time of creating a transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "SelectedPayPalFinancingOptionDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "appUsedForScanning", - "description": "The application used by the payer to scan the QR code.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PayPalRetailAppUsedForScanning", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PayPalTransactionPayload", - "description": "Top-level output field from creating a PayPal transaction.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transaction", - "description": "The transaction representing the charge on the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAgreementWithPurchasePaymentMethod", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType" : null - }, - "isDeprecated" : false, - "deprecationReason" : null - } - ], - "inputFields" : null, - "interfaces" : [], - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind" : "ENUM", - "name" : "PayPalUserAction", - "description" : "PayPal User action type.", - "fields" : null, - "inputFields" : null, - "interfaces" : null, - "enumValues" : [ - { - "name" : "COMMIT", - "description" : null, - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "CONTINUE", - "description" : null, - "isDeprecated" : false, - "deprecationReason" : null - } - ], - "possibleTypes" : null - }, - { - "kind" : "INTERFACE", - "name" : "Payment", - "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).", - "fields" : [ - { - "name" : "id", - "description" : "Unique identifier.", - "args" : [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the payment was created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "orderId", - "description": "The order ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The current status of this payment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "statusHistory", - "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.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAccountId", - "description": "The ID of the merchant account that processed this payment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "How the payment was created.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethodSnapshot", - "description": "Snapshot of payment method details used to create the payment, preserved at the time the transaction was created. This will always be present.", - "args": [], - "type": { - "kind": "UNION", - "name": "PaymentMethodSnapshot", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - } - ] - }, - { - "kind": "OBJECT", - "name": "PaymentConnection", - "description": "A paginated list of transactions and refunds.", - "fields": [ - { - "name": "edges", - "description": "A list of transactions and refunds.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PaymentConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of transactions and refunds contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PaymentConnectionEdge", - "description": "A transaction or refund within a PaymentConnection.", - "fields": [ - { - "name": "cursor", - "description": "This transaction or refund's location within the PaymentConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The transaction or refund.", - "args": [], - "type": { - "kind": "INTERFACE", - "name": "Payment", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INTERFACE", - "name": "PaymentContext", - "description": "Context associated with a transaction.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the payment context was created.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "updatedAt", - "description": "Date and time when the payment context was updated.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "CustomActionsPaymentContext", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "LocalPaymentContext", - "ofType": null - } - ] - }, - { - "kind": "ENUM", - "name": "PaymentInitiator", - "description": "The initiator of the payment. Payment can either be merchant-initiated or customer-initiated.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "MOTO", - "description": "Transactions that are initiated by the customer via the merchant by mail or telephone.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING", - "description": "Transactions that are initiated by the merchant for subsequent recurring payments (e.g. subscriptions with a fixed amount on a predefined schedule).", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING_FIRST", - "description": "Transactions initiated by the customer that represent the first in a series of recurring payments or subscription.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNSCHEDULED", - "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).", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PaymentLevelFeeReport", - "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).", - "fields": [ - { - "name": "url", - "description": "The URL where the generated report is stored. Download the report from this URL.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PaymentMethod", - "description": "Top-level field representing a payment method.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier. May be the same as ID for single-use payment methods.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "usage", - "description": "Whether a payment method may be used only once or multiple times.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentMethodUsage", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the payment method was vaulted.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "details", - "description": "Details about the payment method specific to the type (e.g. credit card, PayPal account).", - "args": [], - "type": { - "kind": "UNION", - "name": "PaymentMethodDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verifications", - "description": "A paginated list of verifications that have been run against the payment method.", - "args": [ - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VerificationConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customer", - "description": "The customer that the payment method belongs to.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Customer", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PaymentMethodConnection", - "description": "A paginated list of payment methods.", - "fields": [ - { - "name": "edges", - "description": "A list of payment methods.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PaymentMethodConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of payment methods contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PaymentMethodConnectionEdge", - "description": "A payment method within a PaymentMethodConnection.", - "fields": [ - { - "name": "cursor", - "description": "This payment method's location within the PaymentMethodConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PaymentMethodDeletionInitiator", - "description": "Initiator of a payment method delete request.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CUSTOMER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MERCHANT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "PaymentMethodDetails", - "description": "A union of all possible payment method details. PaymentMethodDetails contain information for display purposes, payment method management, and processing.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "CustomActionsPaymentMethodDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "CreditCardDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PayPalAccountDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SamsungPayCardDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "VenmoAccountDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "UsBankAccountDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SEPADirectDebitAccountDetails", - "ofType": null - } - ] - }, - { - "kind": "OBJECT", - "name": "PaymentMethodOrigin", - "description": "Information about how the customer provided a payment method, such as via a digital wallet.", - "fields": [ - { - "name": "type", - "description": "An enum identifying the origin of the payment method.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentMethodOriginType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "details", - "description": "When available, additional details specific to the origin.", - "args": [], - "type": { - "kind": "UNION", - "name": "PaymentMethodOriginDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "PaymentMethodOriginDetails", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "ApplePayOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "GooglePayOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "MasterpassOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "NetworkTokenOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SamsungPayOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "VisaCheckoutOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PayPalLocalPaymentOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "CardPresentOriginDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "EmvCardOriginDetails", - "ofType": null - } - ] - }, - { - "kind": "ENUM", - "name": "PaymentMethodOriginType", - "description": "A value identifying the third-party origin from which a customer provided their payment method.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "APPLE_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GOOGLE_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IN_STORE_READER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MASTERPASS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NETWORK_TOKEN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SAMSUNG_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VISA_CHECKOUT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "PaymentMethodSnapshot", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "CustomActionsPaymentMethodDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "CreditCardDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PayPalTransactionDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "VenmoAccountDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "UsBankAccountDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "LocalPaymentDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "CreditCardTransactionDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SEPADirectDebitTransactionDetails", - "ofType": null - } - ] - }, - { - "kind": "ENUM", - "name": "PaymentMethodSnapshotSearchType", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ALIPAY_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "BANCONTACT_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "BLIK_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "BOLETOBANCARIO_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_VIA_APPLE_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_VIA_GOOGLE_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_VIA_MASTERPASS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_VIA_NETWORK_TOKEN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_VIA_SAMSUNG_PAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CREDIT_CARD_VIA_VISA_CHECKOUT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "EPS_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GIROPAY_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GRABPAY_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IDEAL_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOCAL_PAYMENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MULTIBANCO_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MYBANK_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OXXO_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "P24_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYU_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAY_UPON_INVOICE_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SATISPAY_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SEPA_DIRECT_DEBIT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SEPA_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SOFORT_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SWISH_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRUSTLY_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "US_BANK_ACCOUNT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VENMO_ACCOUNT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VERKKOPANKKI_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VIPPS_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WECHAT_PAY_VIA_PAYPAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PaymentMethodUsage", - "description": "Possible usages for payment methods.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "MULTI_USE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SINGLE_USE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PaymentMethodVerificationOptionsInput", - "description": "Input fields that specify options for verifying the vaulted payment method. Only applicable for payment method types that suport verification.", - "fields": null, - "inputFields": [ - { - "name": "merchantAccountId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "skip", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PaymentNetworkResponse", - "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.", - "fields": [ - { - "name": "code", - "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).", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "message", - "description": "The network response text.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PaymentReaderInputMode", - "description": "The input mode used on the payment reader to facilitate an in-store transaction.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CONTACT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CONTACTLESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MAGSTRIPE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MAGSTRIPE_FALLBACK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MANUAL_KEY_ENTRY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VAULT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PaymentSearchInput", - "description": "Input fields for searching for any type of Payment.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find payments with an ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "type", - "description": "Find payments by their type. Use this field to search for payments by the direction of money movement.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentTypeInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "status", - "description": "Find payments with a given status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentStatusInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "statusTransition", - "description": "Find payments based on the time at which they transitioned to a given status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentStatusTransitionInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "createdAt", - "description": "Find payments based on the time they were created.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "Find payments for a given amount or currency.", - "type": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountSearchInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "description": "Find payments with a given orderId.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "Find payments processed through a merchant account ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customer", - "description": "Find payments with a given customer.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentCustomerInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disbursementDate", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "source", - "description": "Find payments created with a given source.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentSourceInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "settlementBatchId", - "description": "Find payments by the batch ID under which the payment was submitted for settlement.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethod", - "description": "Find payments based on information about the payment method used for the payment.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPaymentMethodInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "facilitatorOAuthApplicationClientId", - "description": "Find payments created by a third party via the Grant API using a given OAuth application client ID.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "userId", - "description": "Find payments with a user ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "storeId", - "description": "Find payments by the ID of the store that the transaction was processed in.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PaymentSearchType", - "description": "The type of a Payment, based primarily on implementing type.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "DETACHED_REFUND", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REFUND", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRANSACTION", - "description": "The payment is a Transaction.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PaymentSource", - "description": "The origin of a request that created or changed a transaction or refund.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "API", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CONTROL_PANEL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYMENT_READER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNKNOWN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "PaymentStatus", - "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`.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AUTHORIZATION_EXPIRED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHORIZED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHORIZING", - "description": "If a payment remains in a status of `AUTHORIZING`, [contact us for assistance](https://help.braintreepayments.com?issue=TransactionProcessingQuestion).", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FAILED", - "description": "An error occurred when sending the payment to the downstream processor. See the payment's `statusHistory` for the exact error.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GATEWAY_REJECTED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROCESSOR_DECLINED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SETTLED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SETTLEMENT_CONFIRMED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SETTLEMENT_DECLINED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SETTLEMENT_PENDING", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SETTLING", - "description": "The payment is in the process of being settled. This is a transitory state, and will resolve to a status of `SETTLED`.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUBMITTED_FOR_SETTLEMENT", - "description": "The payment has been successfully captured, and will be included in the next settlement batch, at which time it will become settled.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VOIDED", - "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.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "description": "Status event in the [lifecycle of a payment](https://articles.braintreepayments.com/get-started/transaction-lifecycle).", - "fields": [ - { - "name": "status", - "description": "New status of the payment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the status event occurred.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "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`.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "Source that caused the status event to occur.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether this is the final state for the payment. If false, this transaction will pass into another subsequent state.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "AuthorizationExpiredEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "AuthorizedEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "FailedEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "GatewayRejectedEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "ProcessorDeclinedEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SettledEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SettlementConfirmedEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SettlementDeclinedEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SettlementPendingEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SettlingEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SubmittedForSettlementEvent", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "VoidedEvent", - "ofType": null - } - ] - }, - { - "kind": "SCALAR", - "name": "Percentage", - "description": "The percentage, as a fixed-point, signed decimal number. For example, define a 19.99% interest rate as `19.99`.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PerformThreeDSecureLookupInput", - "description": "Top-level fields for performing a 3D Secure Lookup.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the merchant account that will be used when charging the payment method.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "dfReferenceId", - "description": "Reference ID used by our MPI provider CardinalCommerce to connect the lookup request to the device data that was previously collected.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of a payment method to perform the lookup on.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The amount you plan to charge the payment method after the 3D Secure authentication.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "transactionInformation", - "description": "Additional information about the transaction when authenticating through 3D Secure.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupTransactionInformationInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cardholderInformation", - "description": "Additional information about the cardholder when authenticating through 3D Secure.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupCardholderInformationInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "requestAuthenticationChallenge", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "clientInformation", - "description": "Information about the client-side lookup process.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupClientInformationInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "dataOnlyRequested", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cardAdd", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PerformThreeDSecureLookupPayload", - "description": "Top-level fields returned when performing a 3D Secure Lookup.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "threeDSecureLookupData", - "description": "Data fields containing information from the MPI provider about the 3D Secure Lookup result.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "ThreeDSecureLookupData", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "PhoneInput", - "description": "The phone number in its international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en).", - "fields": null, - "inputFields": [ - { - "name": "countryPhoneCode", - "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).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "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).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "extensionNumber", - "description": "The extension number.", - "type": { - "kind" : "SCALAR", - "name" : "String", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces" : null, - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind" : "ENUM", - "name" : "PreDisputeProgram", - "description" : "The pre-dispute program of the dispute.", - "fields" : null, - "inputFields" : null, - "interfaces" : null, - "enumValues" : [ - { - "name" : "NONE", - "description" : "The dispute does not have a pre-dispute program.", - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "VISA_RDR", - "description" : "The dispute is part of the Visa Rapid Dispute Resolution (RDR) program.", - "isDeprecated" : false, - "deprecationReason" : null - } - ], - "possibleTypes" : null - }, - { - "kind" : "ENUM", - "name" : "ProcessorDeclineType", - "description" : "Whether the decline is likely to be temporary or persistent. Can be taken into consideration when determining whether to retry a declined charge.", - "fields" : null, - "inputFields" : null, - "interfaces" : null, - "enumValues" : [ - { - "name": "HARD", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SOFT", - "description": "Soft declines result from a temporary issue and can be retried; subsequent charges on the same payment method may be successful.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ProcessorDeclinedEvent", - "description": "Accompanying information for a processor declined transaction.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction was declined by the processor.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "declineType", - "description": "Whether or not the decline is the result of a temporary issue.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ProcessorDeclineType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response and why they declined the transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionAuthorizationProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "networkResponse", - "description": "Fields describing the network response to the authorization request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentNetworkResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "riskDecision", - "description": "Risk decision for this transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "RiskDecision", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Query", - "description": "The top-level Query type. Queries are used to fetch data.", - "fields": [ - { - "name": "ping", - "description": "Returns the literal string 'pong'.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pingInStoreReader", - "description": "Triggers a beep on a connected Reader and returns the Reader information or an error if unable to ping the device.", - "args": [ - { - "name": "readerId", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "viewer", - "description": "The currently authenticated viewer.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Viewer", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "clientConfiguration", - "description": "The client-side environment and payment method configuration.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "ClientConfiguration", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "Fetch any object that extends the Node interface using its ID.", - "args": [ - { - "name": "id", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "idFromLegacyId", - "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.", - "args": [ - { - "name": "legacyId", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "type", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "LegacyIdType", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "report", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Report", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "search", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Search", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paypalFinancingOptions", - "description": "Retrieve PayPal financing options that include payment installment plans.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PayPalFinancingOptionsInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PayPalFinancingOptionsPayload", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inStoreLocations", - "description": "Retrieve a paginated list of all in-store locations.", - "args": [ - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreLocationConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ReaderStatus", - "description": "Indicates the status of a Reader.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "OFFLINE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ONLINE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "RecurringType", - "description": "The type of recurring payment a transaction represents.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "FIRST", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUBSEQUENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNSCHEDULED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Refund", - "description": "A refund of a charge on a payment method, representing an attempt to send money from a previous transaction back to the customer.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the refund was created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount that will be refunded, which can be less than or equal to the original charge amount.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "orderId", - "description": "The order ID for this refund. For PayPal transactions, the PayPal Invoice ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The current status of this refund.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "statusHistory", - "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.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "details", - "description": "Payment method specific details about the refund.", - "args": [], - "type": { - "kind": "UNION", - "name": "RefundPaymentMethodDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAccountId", - "description": "The ID of the merchant account that processed this refund.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "How the refund was created.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundedTransaction", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethodSnapshot", - "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`.", - "args": [], - "type": { - "kind": "UNION", - "name": "PaymentMethodSnapshot", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "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).", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customer", - "description": "The customer that the vaulted payment method (if it exists) belongs to. Equivalent to `refundedTransaction.customer` (if present).", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Customer", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "lineItems", - "description": "Line items for this refund.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "TransactionLineItem", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customFields", - "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.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "CustomField", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "descriptor", - "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).", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionDescriptor", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason" : null - }, - { - "name" : "disbursementDetails", - "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.", - "args" : [], - "type" : { - "kind" : "OBJECT", - "name" : "DisbursementDetails", - "ofType" : null - }, - "isDeprecated" : false, - "deprecationReason" : null - }, - { - "name" : "paymentInitiatedAt", - "description" : "The refund date and time as reported by the in-store payment terminal.", - "args" : [], - "type" : { - "kind" : "SCALAR", - "name" : "Timestamp", - "ofType" : null - }, - "isDeprecated" : false, - "deprecationReason" : null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "Payment", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RefundConnection", - "description": "A paginated list of refunds.", - "fields": [ - { - "name": "edges", - "description": "A list of refunds.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "RefundConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of refunds contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RefundConnectionEdge", - "description": "A transaction within a RefundConnection.", - "fields": [ - { - "name": "cursor", - "description": "This refund's location within the RefundConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The refund.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RefundCreditCardInput", - "description": "Top-level input fields for creating a detached refund on a credit card.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of the credit card to be refunded.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "refund", - "description": "Input fields containing details about the refund.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DetachedRefundInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RefundCreditCardPayload", - "description": "Top-level output field from creating a detached refund for a credit card.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refund", - "description": "The information about the created refund.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RefundInput", - "description": "Specific input fields for describing a refund.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "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.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "description": "The refund's order ID. Defaults to the order ID of the original transaction.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the merchant account that will be used when performing the refund.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "description", - "description": "Description of the refund that is displayed to customers in PayPal email receipts.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "reason", - "description": "Reason of the refund transaction. This field maps to the PayPal refund reason.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lineItems", - "description": "Line items for this refund. Up to 249 line items may be specified.\n\nOnly allowed for Custom Actions transactions.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionLineItemInput", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "RefundPaymentMethodDetails", - "description": "A union of all possible payment method refund details.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "PayPalRefundDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "PayPalLocalPaymentRefundDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "SEPADirectDebitRefundDetails", - "ofType": null - } - ] - }, - { - "kind": "ENUM", - "name": "RefundPolicy", - "description": "Supported refund policies.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "EXCHANGE_ONLY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NO_REFUND_OR_EXCHANGE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REFUND_CARDHOLDER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RefundSearchInput", - "description": "Input fields for searching for refunds.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find refunds with an ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "status", - "description": "Find refunds with the given status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionStatusInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "statusTransition", - "description": "Find payments based on the time at which they transitioned to a given status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentStatusTransitionInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "createdAt", - "description": "Find refunds based on the time they were created.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "Find refunds with a given amount or currency.", - "type": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountSearchInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "description": "Find refunds with a given orderId.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customer", - "description": "Find refunds with a given customer.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentCustomerInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disbursementDate", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "source", - "description": "Find refunds created with a given source.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentSourceInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "settlementBatchId", - "description": "Find refunds by the batch ID under which the refund was submitted for settlement.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethod", - "description": "Find refunds based on information about the payment method used for the refund.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPaymentMethodInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "facilitatorOAuthApplicationClientId", - "description": "Find refunds created by a third party via the Grant API using a given OAuth application client ID.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "userId", - "description": "Find refunds with a user ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "storeId", - "description": "Find refunds by the ID of the store that the transaction was processed in.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RefundTransactionInput", - "description": "Top-level input fields for refunding a transaction.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "The ID of a transaction to be refunded.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "refund", - "description": "Input fields for the details of the refund.", - "type": { - "kind": "INPUT_OBJECT", - "name": "RefundInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RefundTransactionPayload", - "description": "Top-level output field from refunding a transaction.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refund", - "description": "The information about the created refund.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RefundUsBankAccountInput", - "description": "Top-level input fields for creating a detached refund on a US Bank Account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of the US Bank Account to be refunded.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields related to the US bank account being charged.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ChargeUsBankAccountOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "refund", - "description": "Input fields containing details about the refund.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DetachedRefundInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RefundUsBankAccountPayload", - "description": "Top-level output field from creating a detached refund for a US Bank Account.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refund", - "description": "The information about the created refund.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Report", - "description": "Top-level fields returned for a report query.", - "fields": [ - { - "name": "transactionLevelFees", - "description": "Top-level fields returned in the transaction-level fee report query.", - "args": [ - { - "name": "date", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": null, - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionLevelFeeReport", - "ofType": null - }, - "isDeprecated": true, - "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." - }, - { - "name": "paymentLevelFees", - "description": "Top-level fields returned in the payment-level fee report query.", - "args": [ - { - "name": "date", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": null, - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PaymentLevelFeeReport", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestCancelFromInStoreReaderInput", - "description": "Input fields for requesting a cancel during an in-store charge flow.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "inStoreContextId", - "description": "Unique ID for the charge flow.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestChargeFromInStoreReaderInput", - "description": "Input fields for beginning the in-store charge flow.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "ID of the Reader to request a charge from.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "transaction", - "description": "Information about the requested in-store transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreTransactionInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RequestChargeInStoreContext", - "description": "Reference object for an in-store charge request.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this charge request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader from which the charge was requested.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created when the charge was requested. A status of COMPLETE does not indicate a successful payment.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transaction", - "description": "The transaction representing the charge on the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestConfirmationPromptFromInStoreReaderInput", - "description": "Input fields for requesting a confirmation prompt on an in-store reader.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "ID of the Reader to request a confirmation prompt from.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "title", - "description": "Title to be displayed on the in-store reader. 50 character maximum.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "text", - "description": "Text to be displayed on the in-store reader. 65536 character maximum. '\\n' line breaks will be respected.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "alignment", - "description": "The way the text is aligned when displayed on an in-store reader. Defaults to CENTER.", - "type": { - "kind": "ENUM", - "name": "ConfirmationPromptAlignment", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cancellationText", - "description": "Text for the cancellation option to be displayed on the in-store reader. 20 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "confirmationText", - "description": "Text for the confirmation option to be displayed on the in-store reader. 20 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RequestConfirmationPromptInStoreContext", - "description": "Reference object for an in-store reader confirmation prompt.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this confirmation prompt request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader from which the confirmation prompt was requested.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created when the confirmation prompt was requested.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "confirmed", - "description": "The confirmation response collected by the in-store reader.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RequestDisplayInStoreContext", - "description": "Reference object for an in-store display request.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this display request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader from which the display was requested.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created when the display was requested.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestFirmwareUpdateFromInStoreReaderInput", - "description": "Input fields for requesting a firmware update for an in-store reader.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "The in-store reader to receive the firmware update.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RequestFirmwareUpdateInStoreContext", - "description": "Reference object for an in-store reader firmware update.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this firmware update request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader for which the firmware update was requested.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created when the firmware update was requested.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestItemDisplayFromInStoreReaderInput", - "description": "Input fields for beginning the in-store display line items flow.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "ID of the Reader to display items on.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "displayItems", - "description": "Items to be displayed on the in-store reader. Up to 249 items may be specified.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreDisplayItemInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "tax", - "description": "The total tax amount for the entire transaction, including all display line items.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The total amount for the entire transaction, including tax.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "discountAmount", - "description": "The total discount amount for the entire transaction.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestRefundFromInStoreReaderInput", - "description": "Input fields for beginning the in-store refund flow.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "ID of the Reader to request a refund from.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "refund", - "description": "Information about the requested in-store refund.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreRefundInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RequestRefundInStoreContext", - "description": "Reference object for an in-store refund request.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this refund request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader from which the refund was requested.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created when the refund was requested. A status of COMPLETE does not indicate a successful payment.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refund", - "description": "The refund representing the refund on the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestSignaturePromptFromInStoreReaderInput", - "description": "Input fields for requesting a signature prompt on an in-store reader.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "ID of the Reader to request a signature prompt from.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "title", - "description": "Title to be displayed on the in-store reader. 50 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cancellationText", - "description": "Text for the cancellation option to be displayed on the in-store reader. 20 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "confirmationText", - "description": "Text for the confirmation option to be displayed on the in-store reader. 20 character maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RequestSignaturePromptInStoreContext", - "description": "Reference object for an in-store reader signature prompt.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this signature prompt request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader from which the signature prompt was requested.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created when the signature prompt was requested.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "signatureData", - "description": "The signature data collected by the in-store reader. Base64 encoded PNG image.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestTextDisplayFromInStoreReaderInput", - "description": "Input fields for beginning the in-store display text flow.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "ID of the Reader to request a text display from.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "text", - "description": "Text to be displayed on the in-store reader. 255 character maximum. '\\n' line breaks will be respected.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RequestVaultFromInStoreReaderInput", - "description": "Input fields for beginning the in-store charge flow.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "ID of the Reader to request a vault from.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "verification", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "PaymentMethodVerificationOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "ID of the customer to associate the resulting multi-use payment method with.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RequestVaultInStoreContext", - "description": "Reference object for an in-store vault request.", - "fields": [ - { - "name": "id", - "description": "A unique ID for this vault request.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reader", - "description": "The reader from which the vault was requested.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreReader", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The status of the context created when the vault was requested.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "InStoreContextStatus", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A payment method that has been stored in a merchant's vault and can be reused.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verification", - "description": "The verification that was run on the payment method prior to vaulting.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Verification", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "InStoreContextResult", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ReverseRefundInput", - "description": "Input fields for reversing a refund.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "refundId", - "description": "The ID of the refund to reverse.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ReverseTransactionInput", - "description": "Input fields for reversing a transaction.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "The ID of the transaction to reverse.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ReverseTransactionPayload", - "description": "Top-level output field for reversing a transaction.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "reversal", - "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.", - "args": [], - "type": { - "kind": "UNION", - "name": "TransactionReversal", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Right", - "description": "A right assigned to a user.", - "fields": [ - { - "name": "name", - "description": "A human-readable name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "RiskData", - "description": "Data from advanced risk evaluations.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "decision", - "description": "The risk decision on whether the transaction should be permitted.", - "args": [], - "type": { - "kind": "ENUM", - "name": "RiskDecision", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "decisionReasons", - "description": "The reasons for the decision from the fraud service provider.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deviceDataCaptured", - "description": "Whether data associated with the customer's device was captured and used in the decision process.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fraudServiceProvider", - "description": "The fraud service provider used to generate the risk decision.", - "args": [], - "type": { - "kind": "ENUM", - "name": "FraudServiceProvider", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "liabilityShift", - "description": "Liability Shift information in the event of a chargeback.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "LiabilityShift", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "score", - "description": "The numeric risk score assigned by the fraud service provider.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "RiskDataInput", - "description": "Input fields for data used by processors for risk analysis.", - "fields": null, - "inputFields": [ - { - "name": "customerBrowser", - "description": "The User-Agent header provided by the customer's browser, which gives information about the browser. Maximum 255 characters.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerIp", - "description": "The customer's IP address.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "deviceData", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "RiskDecision", - "description": "The risk decision provides further context on how a transaction was scored for risk by Braintree.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "APPROVE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DECLINE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NOT_EVALUATED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REVIEW", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Role", - "description": "Groups of rights assigned to the user.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "A human-readable name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isAccountAdmin", - "description": "Whether the role grants account admin status.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "rights", - "description": "The rights associated with the role.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Right", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SEPADirectDebitAccountDetails", - "description": "Details about a SEPA Direct Debit account.", - "fields": [ - { - "name": "merchantOrPartnerCustomerId", - "description": "Merchant or Partner Customer ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "last4", - "description": "Last 4 characters of IBAN number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bankReferenceToken", - "description": "Bank reference token.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mandateType", - "description": "Mandate type.", - "args": [], - "type": { - "kind": "ENUM", - "name": "MandateType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SEPADirectDebitRefundDetails", - "description": "Refund-related details for SEPA Direct Debit transactions.", - "fields": [ - { - "name": "refundId", - "description": "The SEPA Direct Debit refund ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refundedFee", - "description": "Refunded transaction fee charged by SEPA Direct Debit.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentId", - "description": "PayPal V2 OrderId.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SEPADirectDebitTransactionDetails", - "description": "Details about a SEPA Direct Debit account.", - "fields": [ - { - "name": "captureId", - "description": "If funds for the transaction have settled, the PayPal ID for the capture of funds.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactionFee", - "description": "The fee charged by PayPal for the transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentId", - "description": "PayPal V2 OrderId.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SamsungPayCardDetails", - "description": "Details about a Samsung Pay card.", - "fields": [ - { - "name": "brand", - "description": "The display name of the card brand, e.g. \"Visa\" or \"American Express\".", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "brandCode", - "description": "A static code identifying the card brand of the FPAN (the customer's actual backing card).", - "args": [], - "type": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "binData", - "description": "Information about the card based on its BIN. This BIN will differ from the BIN of the source (customer's actual) card.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "BinRecord", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sourceCardLast4", - "description": "The last four digits of the FPAN (the customer's actual backing card).", - "args": [], - "type": { - "kind": "SCALAR", - "name": "CreditCardLast4", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SamsungPayCardInput", - "description": "Input fields for a Samsung Pay card.", - "fields": null, - "inputFields": [ - { - "name": "cryptogram", - "description": "A one-time-use string generated by the token requester to validate the transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "eCommerceIndicator", - "description": "A two-digit string that should be passed along in the authorization message.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ECommerceIndicator", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "expirationMonth", - "description": "A two-digit string representing the expiration month of the DPAN.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Month", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "expirationYear", - "description": "A four-digit string representing the expiration year of the DPAN.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Year", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "number", - "description": "The card number provided by Samsung and used in processing. This is a digitized PAN (DPAN), not the backing card number (FPAN).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CreditCardNumber", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "sourceCardLast4", - "description": "The last four digits of the FPAN (the cardholder's backing card).", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CreditCardLast4", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SamsungPayConfiguration", - "description": "Configuration for Samsung Pay on Android.", - "fields": [ - { - "name": "displayName", - "description": "A string used to identify the merchant to the customer.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "environment", - "description": "The Samsung Pay environment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "SamsungPayEnvironment", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "serviceId", - "description": "The Samsung Pay service ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "samsungAuthorization", - "description": "Authorization to use when tokenizing Samsung Pay.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "supportedCardBrands", - "description": "A list of card brands supported by the merchant for Samsung Pay.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "SamsungPayEnvironment", - "description": "The environment being used for Samsung Pay.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "PRODUCTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SANDBOX", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SamsungPayOriginDetails", - "description": "Additional information about the payment method specific to Samsung Pay.", - "fields": [ - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SandboxSettleTransactionInput", - "description": "Top-level input fields for settling a transaction in the sandbox environment.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "Id of the transaction to force settlement in the sandbox environment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "settlementState", - "description": "The target settlement state for the transaction in the sandbox environment.", - "type": { - "kind": "ENUM", - "name": "SandboxSettlementState", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "SandboxSettlementState", - "description": "The settlement state when forcing transaction settlement in the sandbox environment.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "SETTLED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SETTLEMENT_DECLINED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ScaExemptionType", - "description": "The type of Strong Customer Authentication Exemption.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "LOW_VALUE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SECURE_CORPORATE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRANSACTION_RISK_ANALYSIS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRUSTED_BENEFICIARY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Search", - "description": "Top-level fields returned for a search query.", - "fields": [ - { - "name": "transactions", - "description": "A paginated list of transactions that match the TransactionSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "TransactionConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refunds", - "description": "A paginated list of refunds that match the RefundSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "RefundSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "RefundConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "payments", - "description": "A paginated list of all types of Payment that match the PaymentSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PaymentSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "PaymentConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "disputes", - "description": "A paginated list of disputes that match the DisputeSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "DisputeSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "DisputeConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verifications", - "description": "A paginated list of verifications that match the VerificationSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "VerificationSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "VerificationConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customers", - "description": "A paginated list of customers that match the CustomerSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomerSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "CustomerConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "businessAccountCreationRequests", - "description": "A paginated list of business account creation requests that match the BusinessAccountCreationRequestSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "BusinessAccountCreationRequestSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "BusinessAccountCreationRequestConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inStoreReaders", - "description": "A paginated list of in-store readers that match the InStoreReaderSearchInput.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreReaderSearchInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "first", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "after", - "description": null, - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "InStoreReaderConnection", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchChargebackProtectionLevelInput", - "description": "Deprecated: Please use `SearchDisputeProtectionLevelInput` instead.\n\nInput fields for searching for a dispute with a given chargeback protection level.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "The dispute's chargeback protection level is exactly this value.", - "type": { - "kind": "ENUM", - "name": "ChargebackProtectionLevel", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "Dispute's chargeback protection level is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "ChargebackProtectionLevel", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardBrandCodeInput", - "description": "Input fields for searching for payments by credit card brand.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "Credit card brand code is exactly this value.", - "type": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "Credit card brand code is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardExpirationDateInput", - "description": "Input fields for searching for payments by payment method snapshot credit card expiration date criteria.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "Field is exactly this value.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardExpirationMonthYearInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "isNot", - "description": "Field is not this value.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardExpirationMonthYearInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardExpirationMonthYearInput", - "description": "Input fields for searching for payments by payment method snapshot credit card expiration date criteria.", - "fields": null, - "inputFields": [ - { - "name": "expirationMonth", - "description": "The month of the credit card expiration as MM.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "expirationYear", - "description": "The year of the credit card expiration as YYYY.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardNumberInput", - "description": "Input fields for searching for payments by payment method snapshot credit card number criteria.", - "fields": null, - "inputFields": [ - { - "name": "startsWith", - "description": "Up to the first six digits of the credit card number (the credit card's BIN).", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "endsWith", - "description": "Up to four digits of the last four digits of the credit card number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "description": "Input fields for searching for a date. These ranges are precise to the day.", - "fields": null, - "inputFields": [ - { - "name": "greaterThanOrEqualTo", - "description": "Date is greater than or equal to this value.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lessThanOrEqualTo", - "description": "Date is less than or equal to this value.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchDisputeProtectionLevelInput", - "description": "Input fields for searching for a dispute with a given protection level.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "The dispute's protection level is exactly this value.", - "type": { - "kind": "ENUM", - "name": "DisputeProtectionLevel", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "The dispute's protection level is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "DisputeProtectionLevel", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchDisputeReasonInput", - "description": "Input fields for searching for a dispute with a given reason description.", - "fields": null, - "inputFields": [ - { - "name": "in", - "description": "The dispute reason is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "DisputeReason", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchDisputeStatusInput", - "description": "Input fields for searching for a dispute with a given status.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "The dispute status is exactly this value.", - "type": { - "kind": "ENUM", - "name": "DisputeStatus", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "The dispute status is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "DisputeStatus", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchDisputeTypeInput", - "description": "Input fields for searching for a dispute with a given type.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "The dispute type is exactly this value.", - "type": { - "kind": "ENUM", - "name": "DisputeType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "The dispute type is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "DisputeType", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentCreditCardDetailsInput", - "description": "Input fields for searching for payments by payment method snapshot credit card details criteria.", - "fields": null, - "inputFields": [ - { - "name": "number", - "description": "The credit card number used for the payment.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardNumberInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "expirationDate", - "description": "Find payments based on the expiration date of the credit card used for the payment.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardExpirationDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "uniqueNumberIdentifier", - "description": "The unique identifier of the credit card number used for the payment.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cardholderName", - "description": "The card holder name of the credit card number used for the payment.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "brandCode", - "description": "The brand code of the credit card number used for the payment.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchCreditCardBrandCodeInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentCustomerInput", - "description": "Input fields for searching payments by customer.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find payments with a given customer ID.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "firstName", - "description": "Find payments with a given first name.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lastName", - "description": "Find payments with a given last name.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "company", - "description": "Find payments with a given customer company.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "email", - "description": "Find payments with a customer email.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentMethodSnapshotTypeInput", - "description": "Input fields for searching transactions by payment method snapshot type.", - "fields": null, - "inputFields": [ - { - "name": "is", - "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.", - "type": { - "kind": "ENUM", - "name": "PaymentMethodSnapshotSearchType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PaymentMethodSnapshotSearchType", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPayPalDetailsInput", - "description": "Input fields for searching for payments by payment method snapshot PayPal details criteria.", - "fields": null, - "inputFields": [ - { - "name": "email", - "description": "\"The email address of the PayPal payer.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPaymentMethodInput", - "description": "Input fields for searching for payments by payment method criteria.", - "fields": null, - "inputFields": [ - { - "name": "paymentMethodId", - "description": "The ID of the vaulted payment method used for the payment.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodSnapshot", - "description": "The snapshot of the payment method at the time of payment creation.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPaymentMethodSnapshotInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPaymentMethodSnapshotInput", - "description": "Input fields for searching for payments by payment method snapshot criteria.", - "fields": null, - "inputFields": [ - { - "name": "type", - "description": "Find payments based on the payment instrument type.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentMethodSnapshotTypeInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "creditCardDetails", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentCreditCardDetailsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "payPalDetails", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPayPalDetailsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "sepaDirectDebitDetails", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentSEPADirectDebitDetailsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentSEPADirectDebitDetailsInput", - "description": "Input field for searching for payments by payment method snapshot SEPA Direct Debit details criteria.", - "fields": null, - "inputFields": [ - { - "name": "paymentId", - "description": "PayPal V2 OrderId.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentSourceInput", - "description": "Input fields for searching for a transaction or refund created with a given source.", - "fields": null, - "inputFields": [ - { - "name": "in", - "description": "The transaction source is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentStatusInput", - "description": "Input fields for searching for a transaction or refund with a given status.", - "fields": null, - "inputFields": [ - { - "name": "in", - "description": "The transaction status is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentStatusTransitionInput", - "description": "Payment status transition times.", - "fields": null, - "inputFields": [ - { - "name": "failedAt", - "description": "Find transactions with a given failed at time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "settledAt", - "description": "Find transactions with a given settled at time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "submittedForSettlementAt", - "description": "Find transactions with a given submitted for settlement time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue" : null - }, - { - "name" : "voidedAt", - "description" : "Find transactions with a given voided at time.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "SearchTimestampInput", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "authorizationExpiredAt", - "description" : "Find transactions with a given authorization expired at time.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "SearchTimestampInput", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "authorizedAt", - "description" : "Find transactions with a given authorized at time.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "SearchTimestampInput", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "gatewayRejectedAt", - "description" : "Find transactions with a given gateway rejected at time.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "SearchTimestampInput", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "processorDeclinedAt", - "description" : "Find transactions with a given processor declined at time.", - "type" : { - "kind" : "INPUT_OBJECT", - "name" : "SearchTimestampInput", - "ofType" : null - }, - "defaultValue" : null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentTypeInput", - "description": "Input fields for searching for payments by implementing type.", - "fields": null, - "inputFields": [ - { - "name": "in", - "description": "The payment is a transaction and/or a refund.", - "type" : { - "kind" : "LIST", - "name" : null, - "ofType" : { - "kind" : "NON_NULL", - "name" : null, - "ofType" : { - "kind" : "ENUM", - "name" : "PaymentSearchType", - "ofType" : null - } - } - }, - "defaultValue" : null - } - ], - "interfaces" : null, - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind" : "INPUT_OBJECT", - "name" : "SearchPreDisputeProgramInput", - "description" : "Input fields for searching for a dispute with a given pre-dispute program.", - "fields" : null, - "inputFields" : [ - { - "name" : "is", - "description" : "The dispute's pre-dispute program is exactly this value.", - "type" : { - "kind" : "ENUM", - "name" : "PreDisputeProgram", - "ofType" : null - }, - "defaultValue" : null - }, - { - "name" : "in", - "description" : "The dispute's pre-dispute program is one of these values.", - "type" : { - "kind" : "LIST", - "name" : null, - "ofType" : { - "kind" : "NON_NULL", - "name" : null, - "ofType" : { - "kind" : "ENUM", - "name" : "PreDisputeProgram", - "ofType" : null - } - } - }, - "defaultValue" : null - } - ], - "interfaces" : null, - "enumValues" : null, - "possibleTypes" : null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchRangeInput", - "description": "Input fields for searching for a range.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "Field is exactly this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "greaterThanOrEqualTo", - "description": "Field is greater than or equal to this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lessThanOrEqualTo", - "description": "Field is less than or equal to this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchSoftwareVersionInput", - "description": "Input fields for searching for a version number.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "Field is exactly this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "isNot", - "description": "Field is not this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "startsWith", - "description": "Field starts with this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "contains", - "description": "Field contains this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "description": "Input fields for searching text fields.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "Field is exactly this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "isNot", - "description": "Field is not this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "startsWith", - "description": "Field starts with this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "endsWith", - "description": "Field ends with this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "contains", - "description": "Field contains this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "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.", - "fields": null, - "inputFields": [ - { - "name": "greaterThanOrEqualTo", - "description": "Timestamp is greater than or equal to this value.", - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lessThanOrEqualTo", - "description": "Timestamp is less than or equal to this value.", - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionSourceInput", - "description": "Input fields for searching for a transaction created with a given source.", - "fields": null, - "inputFields": [ - { - "name": "in", - "description": "The transaction source is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionStatusInput", - "description": "Input fields for searching for a transaction with a given status.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "The transaction status is exactly this value.", - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "The transaction status is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionStatusTransitionInput", - "description": "Transaction status transition times.", - "fields": null, - "inputFields": [ - { - "name": "failedAt", - "description": "Find transactions with a given failed at time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "settledAt", - "description": "Find transactions with a given settled at time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "submittedForSettlementAt", - "description": "Find transactions with a given submitted for settlement time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "voidedAt", - "description": "Find transactions with a given voided at time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "description": "Input fields for searching for specific values.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "Field is exactly this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "Field is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SearchVerificationStatusInput", - "description": "Input fields for searching for a verification with a given status.", - "fields": null, - "inputFields": [ - { - "name": "is", - "description": "The verification status is exactly this value.", - "type": { - "kind": "ENUM", - "name": "VerificationStatus", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "in", - "description": "The verification status is one of these values.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "VerificationStatus", - "ofType": null - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SelectedPayPalFinancingOptionDetails", - "description": "Details about a selected financing option by a PayPal buyer.", - "fields": [ - { - "name": "term", - "description": "Total number of payments over which to finance the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "monthlyPayment", - "description": "The amount for each monthly payment.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "discountPercentage", - "description": "The percent discount off the total transaction amount due to the selected financing option.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Percentage", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "discountAmount", - "description": "The amount reduced from the total transaction amount.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "SelectedPayPalFinancingOptionInput", - "description": "Input fields indicating a selected financing option by a PayPal buyer.", - "fields": null, - "inputFields": [ - { - "name": "term", - "description": "Total number of payments over which to finance the transaction.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "currencyCode", - "description": "The currency code for the monthly payment and discount amount.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "monthlyPayment", - "description": "The amount for each monthly payment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "discountPercentage", - "description": "The percent discount off the total transaction amount due to the selected financing option.", - "type": { - "kind": "SCALAR", - "name": "Percentage", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "discountAmount", - "description": "The amount reduced from the total transaction amount.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SettledEvent", - "description": "Accompanying information for a settled transaction.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction was settled.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionSettlementProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "settlementBatchId", - "description": "The ID of the settlement batch in which the transaction was processed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SettlementConfirmedEvent", - "description": "Accompanying information for a settlement confirmed transaction.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction became settlement confirmed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response to the settlement request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionSettlementProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SettlementDeclinedEvent", - "description": "Accompanying information for a settlement declined transaction.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the processor declined to settle this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response to the settlement request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionSettlementProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SettlementPendingEvent", - "description": "Accompanying information for a settlement pending transaction. This typically only occurs for PayPal transactions.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction became settlement pending.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response to the settlement request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionSettlementProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SettlingEvent", - "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.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction began settling.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the transaction for this status event. This should match the amount submitted for settlement.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "String", - "description": "Built-in String", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "SubmittedForSettlementEvent", - "description": "Accompanying information for a transaction that is submitted for settlement. This status indicates that the transaction is scheduled to be settled.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction was submitted for settlement.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount that was submitted for settlement. This can differ from the authorized amount, but by default is the same.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TaxInfoInput", - "description": "Input fields for local payment tax information.", - "fields": null, - "inputFields": [ - { - "name": "identifier", - "description": "The payer's tax identifier value.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "type", - "description": "The payer's tax identifier type.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "TaxInfoType", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "TaxInfoType", - "description": "The type of tax identifier.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BR_CNPJ", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "BR_CPF", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ThreeDSecureAuthentication", - "description": "Information about the 3D Secure authentication for a payment method.", - "fields": [ - { - "name": "cavv", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "directoryServerTransactionId", - "description": "A unique identifier for the 3D Secure interaction with the card brand directory server.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "eciFlag", - "description": "The electronic commerce indicator.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ECommerceIndicator", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "liabilityShifted", - "description": "A boolean indicating if the card has received liability shift.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "liabilityShiftPossible", - "description": "A boolean indicating if the card is eligible for liability shift.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cardEnrolled", - "description": "Indicates whether the card is enrolled in a 3D Secure program.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureCardEnrolled", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authenticationStatus", - "description": "The 3D Secure authentication status of the card.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "version", - "description": "The version of the 3D Secure protocol used during authentication.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "xId", - "description": "A unique identifier for the 3D Secure interaction with the provider.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "threeDSecureServerTransactionId", - "description": "A unique identifier for the 3D Secure interaction with the 3D Secure server.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "acsTransactionId", - "description": "A unique identifier for the 3D Secure interaction with the access control server.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paresStatus", - "description": "Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 1.0 authentications.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatusIndicator", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactionStatus", - "description": "Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 2.0 authentications.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatusIndicator", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactionStatusReason", - "description": "Indicates the reason for the transaction status. This will be null if status is `SUCCESSFUL_AUTHENTICATION`.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationAcsWindowSize", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "FULL_PAGE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "W250_H400", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "W390_H400", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "W500_H600", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "W600_H400", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationDeliveryTimeframe", - "description": "Indicates the delivery timeframe if applicable.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ELECTRONIC_DELIVERY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OVERNIGHT_SHIPPING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SAME_DAY_SHIPPING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TWO_OR_MORE_DAY_SHIPPING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureAuthenticationInput", - "description": "Input fields for passing auxillary 3D Secure information manually, as opposed to tokenized on a single-use payment method ID.", - "fields": null, - "inputFields": [ - { - "name": "authenticationId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "passThrough", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecurePassThroughInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationMerchantProductCode", - "description": "Merchant product code.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ACCOMMODATION_RETAIL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AIRLINE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CAR_RENTAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CASH_DISPENSING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DIGITAL_GOODS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FUEL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GENERAL_RETAIL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LUXURY_RETAIL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OTHER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RESTAURANT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SERVICES", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TRAVEL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationShippingType", - "description": "Indicates the shipping type for the transaction.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "DIGITAL_GOODS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OTHER", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SHIP_TO_ADDRESS_ON_FILE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SHIP_TO_BILLING_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SHIP_TO_OTHER_ADDRESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SHIP_TO_STORE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TICKETS_NOT_SHIPPED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatus", - "description": "The 3D Secure authentication status of the card.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AUTHENTICATE_ATTEMPT_SUCCESSFUL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_ERROR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_FAILED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_FAILED_ACS_ERROR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_FRICTIONLESS_FAILED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_REJECTED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_SIGNATURE_VERIFICATION_FAILED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_SUCCESSFUL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATE_SUCCESSFUL_ISSUER_NOT_PARTICIPATING", - "description": null, - "isDeprecated": true, - "deprecationReason": "No longer applicable." - }, - { - "name": "AUTHENTICATE_UNABLE_TO_AUTHENTICATE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATION_BYPASSED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AUTHENTICATION_UNAVAILABLE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CHALLENGE_REQUIRED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DATA_ONLY_SUCCESSFUL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOOKUP_BYPASSED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOOKUP_CARD_ERROR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOOKUP_ENROLLED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOOKUP_ERROR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOOKUP_FAILED_ACS_ERROR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOOKUP_NOT_ENROLLED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LOOKUP_SERVER_ERROR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNSUPPORTED_ACCOUNT_TYPE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNSUPPORTED_CARD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNSUPPORTED_THREE_D_SECURE_VERSION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatusIndicator", - "description": "Indicates the current status of the 3D Secure authentication.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AUTHENTICATION_REJECTED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CHALLENGE_REQUIRED_DECOUPLED_AUTHENTICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CHALLENGE_REQUIRED_FOR_AUTHENTICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FAILED_AUTHENTICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INFORMATIONAL_CHALLENGE_PREFERENCE_ACKNOWLEDGED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUCCESSFUL_ATTEMPTS_TRANSACTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUCCESSFUL_AUTHENTICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNABLE_TO_COMPLETE_AUTHENTICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationTransactionType", - "description": "Indicates the type of transaction for 3D Secure authentication.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ADD_CARD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CARDHOLDER_VERIFICATION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INSTALLMENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MAINTAIN_CARD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PAYMENT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RECURRING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureCardEnrolled", - "description": "Indicates whether the card is enrolled in a 3D Secure program.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BYPASS", - "description": "Authentication has been bypassed. This status will be returned if you set up bypass rules with CardinalCommerce, and they are triggered.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ERROR", - "description": "There was an error in determining whether the card is enrolled in a 3D Secure program.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NO", - "description": "The card is not enrolled.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNAVAILABLE", - "description": "The DS (directory server) or ACS (access control server) is not available for authentication at the time of the request.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "YES", - "description": "The card is enrolled.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "ThreeDSecureCavvAlgorithm", - "description": "A 3D Secure CAVV algorithm. Possible Values: 2 - CVV with ATN, 3 - Mastercard SPA algorithm.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ThreeDSecureConfiguration", - "description": "Configuration for 3D Secure.", - "fields": [ - { - "name": "cardinalAuthenticationJWT", - "description": "Authentication information for initializing Cardinal's songbird.js library.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ThreeDSecureDetails", - "description": "3D Secure information for the payment method.", - "fields": [ - { - "name": "authentication", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "ThreeDSecureAuthentication", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authenticationInsight", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AuthenticationInsightInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "AuthenticationInsight", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cavv", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.cavv instead." - }, - { - "name": "directoryServerTransactionId", - "description": "A unique identifier for the 3D Secure interaction with the card brand directory server.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.directoryServerTransactionId instead." - }, - { - "name": "eciFlag", - "description": "The electronic commerce indicator.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ECommerceIndicator", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.eciFlag instead." - }, - { - "name": "liabilityShifted", - "description": "A boolean indicating if the card has received liability shift.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.liabilityShifted instead." - }, - { - "name": "liabilityShiftPossible", - "description": "A boolean indicating if the card is eligible for liability shift.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.liabilityShiftPossible instead." - }, - { - "name": "cardEnrolled", - "description": "Indicates whether the card is enrolled in a 3D Secure program.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureCardEnrolled", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.cardEnrolled instead." - }, - { - "name": "authenticationStatus", - "description": "The 3D Secure authentication status of the card.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatus", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.authenticationStatus instead." - }, - { - "name": "version", - "description": "The version of the 3D Secure protocol used during authentication.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.version instead." - }, - { - "name": "xId", - "description": "A unique identifier for the 3D Secure interaction with the provider.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.xId instead." - }, - { - "name": "threeDSecureServerTransactionId", - "description": "A unique identifier for the 3D Secure interaction with the 3D Secure server.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.threeDSecureServerTransactionId instead." - }, - { - "name": "acsTransactionId", - "description": "A unique identifier for the 3D Secure interaction with the access control server.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.acsTransactionId instead." - }, - { - "name": "paresStatus", - "description": "Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 1.0 authentications.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatusIndicator", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.paresStatus instead." - }, - { - "name": "transactionStatus", - "description": "Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 2.0 authentications.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationStatusIndicator", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.transactionStatus instead." - }, - { - "name": "transactionStatusReason", - "description": "Indicates the reason for the transaction status. This will be null if status is `SUCCESSFUL_AUTHENTICATION`.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use ThreeDSecureDetails.authentication.transactionStatusReason instead." - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupBillingAddressInput", - "description": " The billing address of the cardholder sent with 3D Secure Lookup requests.", - "fields": null, - "inputFields": [ - { - "name": "givenName", - "description": "The given (first) name associated with the billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "surname", - "description": "The surname (last name) associated with the billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "The billing phone number used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "line1", - "description": "Line 1 of the billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "line2", - "description": "Line 2 of the billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "line3", - "description": "Line 3 of the billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locality", - "description": "City or locality of billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "region", - "description": "State or region of billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "Country code of billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "postalCode", - "description": "Postal code of billing address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupCardholderInformationInput", - "description": "Additional information about the cardholder when authenticating through 3D Secure.", - "fields": null, - "inputFields": [ - { - "name": "billingAddress", - "description": "The billing address of the cardholder.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupBillingAddressInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupClientInformationInput", - "description": "Information about the client side lookup process.", - "fields": null, - "inputFields": [ - { - "name": "sdkVersion", - "description": "Version of the Braintree client-side SDK being used.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "requestedThreeDSecureVersion", - "description": "Version of 3D Secure requested when performing the lookup.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "issuerDeviceDataCollectionMillisecondsElapsed", - "description": "Number of milliseconds taken for the issuer to collect device data.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "issuerDeviceDataCollectionResult", - "description": "Whether device data collection by the issuer succeeded.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "threeDSecureServerDeviceDataCollectionMillisecondsElapsed", - "description": "Number of milliseconds taken for the 3D Secure server to collect device data.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "ThreeDSecureLookupData", - "description": "Data fields containing information from the MPI provider about the 3D Secure Lookup result.", - "fields": [ - { - "name": "acsUrl", - "description": "The URL to use to issue a challenge to the cardholder for 3D Secure authentication.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authenticationId", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "version", - "description": "The version of the 3D Secure protocol used in the authentication.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pareq", - "description": "The \"PAReq\" or \"Payment Authentication Request\" is the encoded request message used to initiate authentication.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "md", - "description": "The unique 3D Secure identifier assigned by Braintree to track the 3D Secure call as it progresses.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "termUrl", - "description": "A fully qualified URL that the customer will be redirected to once the authentication completes.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transactionId", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupShippingAddressInput", - "description": " The shipping address of the transaction to be sent with 3D Secure Lookup requests.", - "fields": null, - "inputFields": [ - { - "name": "givenName", - "description": "The given (first) name associated with the shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "surname", - "description": "The surname (last name) associated with the shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "The shipping phone number used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "line1", - "description": "Line 1 of the shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "line2", - "description": "Line 2 of the shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "line3", - "description": "Line 3 of the shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locality", - "description": "City or locality of shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "region", - "description": "State or region of shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "countryCode", - "description": "Country code of shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "postalCode", - "description": "Postal code of shipping address used for verification.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "ThreeDSecureLookupShippingMethod", - "description": "Indicates the shipping method chosen for the transaction in the 3D Secure lookup.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ELECTRONIC_DELIVERY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GROUND", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OVERNIGHT_EXPEDITED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PRIORITY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SAME_DAY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SHIP_TO_STORE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupTransactionInformationInput", - "description": "Additional information about the transaction when authenticating through 3D Secure.", - "fields": null, - "inputFields": [ - { - "name": "email", - "description": "The email associated with the transaction.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingMethod", - "description": "Indicates the shipping method chosen for the transaction in the 3D Secure lookup.", - "type": { - "kind": "ENUM", - "name": "ThreeDSecureLookupShippingMethod", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "The phone number associated with the transaction.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingAddress", - "description": "The shipping address for the transaction.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecureLookupShippingAddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "workPhoneNumber", - "description": "The work phone number associated with the transaction.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionType", - "description": "Indicates the type of transaction.", - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationTransactionType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "deliveryTimeframe", - "description": "Indicates the delivery timeframe if applicable.", - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationDeliveryTimeframe", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "deliveryEmail", - "description": "For electronic delivery, email address to which the product was delivered.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingType", - "description": "Indicates shipping type chosen for the transaction.", - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationShippingType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "productCode", - "description": "Merchant product code.", - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationMerchantProductCode", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "reorderIndicator", - "description": "Indicates whether the cardholder is reordering merchandise purchased in a previous order.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "preorderIndicator", - "description": "Indicates whether cardholder is placing an order with a future availability or release date.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "preorderDate", - "description": "Expected date that a pre-ordered purchase will be available.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "giftCardAmount", - "description": "The purchase amount total for prepaid gift cards.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "giftCardCurrencyCode", - "description": "ISO 4217 currency code for the gift card purchased.", - "type": { - "kind": "SCALAR", - "name": "CurrencyCodeAlpha", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "giftCardCount", - "description": "Total count of individual prepaid gift cards purchased.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountCreatedDuringTransaction", - "description": "Indicates whether the cardholder created the account during this transaction.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountCreateDate", - "description": "Date the cardholder opened the account.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountChangedDuringTransaction", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountChangeDate", - "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.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountPasswordChangedDuringTransaction", - "description": "Indicates whether the cardholder changed or reset the password on the account during this transaction.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountPasswordChangeDate", - "description": "Date the cardholder changed or reset the password on the account.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "firstUseOfShippingAddress", - "description": "Indicates whether this transaction represents the first use of this shipping address.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingAddressFirstUsageDate", - "description": "Date when the shipping address used for this transaction was first used.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionCountDay", - "description": "Number of transactions (successful or incomplete) for this cardholder account within the last 24 hours.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionCountYear", - "description": "Number of transactions (successful or incomplete) for this cardholder account within the last year.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "addCardAttempts", - "description": "Number of attempts that have been made to add a card to this account in the last 24 hours.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountPurchases", - "description": "Number of purchases with this cardholder account during the previous six months.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "suspiciousActivityObserved", - "description": "Indicates whether the merchant experienced suspicious activity (including previous fraud) on the account.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountNameMatchesShippingName", - "description": "Indicates if the cardholder name on the account is identical to the shipping name used for the transaction.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodAddedDuringTransaction", - "description": "Indicates whether the payment method was added to the cardholder account during this transaction.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodAddedToAccountDate", - "description": "Date the payment method was added to the cardholder account.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "acsWindowSize", - "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.", - "type": { - "kind": "ENUM", - "name": "ThreeDSecureAuthenticationAcsWindowSize", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "sdkMaxTimeout", - "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.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddressMatchesShippingAddress", - "description": "Indicates whether cardholder billing and shipping addresses match.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountId", - "description": "Additional cardholder account information.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "ipAddress", - "description": "The IP address of the cardholder. Both IPv4 and IPv6 formats are supported.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderDescription", - "description": "Brief Description of items purchased.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "taxAmount", - "description": "Tax amount.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "userAgent", - "description": "The exact content of the HTTP user agent header.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "installment", - "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.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "purchaseDate", - "description": "Datetime of original purchase.", - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "recurringEnd", - "description": "The date after which no further recurring authorizations should be performed.", - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "recurringFrequency", - "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.", - "type": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecurePassThroughInput", - "description": "Results of a merchant-performed 3D Secure authentication.", - "fields": null, - "inputFields": [ - { - "name": "eciFlag", - "description": "The value of the electronic commerce indicator (ECI) flag, which indicates the outcome of the 3D Secure authentication.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ECommerceIndicator", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "cavv", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "xId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "threeDSecureServerTransactionId", - "description": "3D Secure server transaction identifier resulting from 3D Secure authentication.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "version", - "description": "The version of 3D Secure authentication used for the transaction. Required on Visa and Mastercard authentications.", - "type": { - "kind": "SCALAR", - "name": "ThreeDSecureVersion", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "authenticationResponse", - "description": "The 3D Secure authentication response status code.", - "type": { - "kind": "SCALAR", - "name": "ThreeDSecureStatusCode", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "directoryServerResponse", - "description": "The 3D Secure directory server response.", - "type": { - "kind": "SCALAR", - "name": "ThreeDSecureStatusCode", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cavvAlgorithm", - "description": "The algorithm used to generate the CAVV value. This is only returned for Mastercard SecureCode transactions (3DS 1.0).", - "type": { - "kind": "SCALAR", - "name": "ThreeDSecureCavvAlgorithm", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "directoryServerTransactionId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "ThreeDSecureStatusCode", - "description": "A raw 3D Secure PARes or VARes response code (e.g. 'Y').", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "ThreeDSecureVersion", - "description": "A 3D Secure authentication version. Must be composed of digits separated by periods (e.g. '1.0.2').", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Timestamp", - "description": "An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Times) timestamp with microsecond precision, in UTC.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeCreditCardInput", - "description": "Top-level input fields for tokenizing a credit card.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "creditCard", - "description": "Input fields for a credit card.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CreditCardInput", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Credit card tokenization options.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TokenizeCreditCardOptionsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeCreditCardOptionsInput", - "description": "Credit card tokenization options.", - "fields": null, - "inputFields": [ - { - "name": "validate", - "description": "Whether to run validations on credit card fields. Validations are not run by default.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizeCreditCardPayload", - "description": "Top-level fields returned from a tokenized credit card.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "token", - "description": "A one-time-use reference to tokenized sensitive information.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `paymentMethod.id` instead." - }, - { - "name": "creditCard", - "description": "Details about the tokenized card.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "CreditCardDetails", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `paymentMethod.details` instead." - }, - { - "name": "singleUseToken", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `paymentMethod` instead." - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authenticationInsight", - "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.", - "args": [ - { - "name": "input", - "description": null, - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "AuthenticationInsightInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "type": { - "kind": "OBJECT", - "name": "AuthenticationInsight", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use paymentMethod.details.threeDSecure.authenticationInsight instead." - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeCustomActionsPaymentMethodInput", - "description": "Top-level input fields for tokenizing Custom Actions.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customActionsPaymentMethod", - "description": "Input fields for a Custom Actions payment method.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomActionsPaymentMethodInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizeCustomActionsPaymentMethodPayload", - "description": "Top-level fields returned from tokenizing a CustomActionsPaymentMethod.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeCvvInput", - "description": "Top-level input fields for tokenizing a CVV, otherwise known as CSC or CVC.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "cvv", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "CVV", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizeCvvPayload", - "description": "Top-level fields returned from a tokenized CVV.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tokenizedCvv", - "description": "A single-use tokenized CVV.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TokenizedCvv", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "singleUseToken", - "description": "A single-use payment method representing just a CVV.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "This mutation does not create a full PaymentMethod. Use `tokenizedCvv` instead." - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeNetworkTokenInput", - "description": "Top-level input field for tokenizing a network token.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "networkToken", - "description": "Input fields for a network token object.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "NetworkTokenInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizeNetworkTokenPayload", - "description": "Top-level fields returned from a tokenized Network Token.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizePayPalBillingAgreementInput", - "description": "Top-level input fields for tokenizing a PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAgreement", - "description": "Input fields for a PayPal Billing Agreement.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PayPalBillingAgreementInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizePayPalBillingAgreementPayload", - "description": "Top-level fields returned from a tokenized PayPal account.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizePayPalOneTimePaymentInput", - "description": "Top-level input fields for tokenizing a PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "Braintree merchant account ID associated with the PayPal account to be used for the One-Time payment tokenization.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paypalOneTimePayment", - "description": "Input fields for a PayPal One-Time Payment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "PayPalOneTimePaymentInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizePayPalOneTimePaymentPayload", - "description": "Top-level fields returned from a tokenized PayPal account.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeSamsungPayCardInput", - "description": "Top-level input field for tokenizing a Samsung Pay card.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "samsungPayCard", - "description": "Input fields for a Samsung Pay card.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "SamsungPayCardInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizeSamsungPayCardPayload", - "description": "Top-level fields returned from a tokenized Samsung Pay card.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "singleUseToken", - "description": "A one-time-use reference to tokenized sensitive information.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `paymentMethod` instead." - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeUsBankAccountInput", - "description": "Top-level input fields for tokenizing a US bank account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "usBankAccount", - "description": "Input fields for a US bank account object.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizeUsBankAccountPayload", - "description": "Top-level fields returned from a tokenized US bank account.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A single-use payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TokenizeUsBankLoginInput", - "description": "Top-level input fields for tokenizing a US bank login.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "usBankLogin", - "description": "Input fields for a US bank login.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "UsBankLoginInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TokenizedCvv", - "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.", - "fields": [ - { - "name": "id", - "description": "Unique identifier for the tokenized CVV.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Transaction", - "description": "A charge on a payment method.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time when the transaction was created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethodSnapshot", - "description": "Snapshot of payment method details used to create the transaction, preserved at the time the transaction was created. This will always be present.", - "args": [], - "type": { - "kind": "UNION", - "name": "PaymentMethodSnapshot", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "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`.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "initialRequestedAuthorizationAmount", - "description": "The initial requested authorization amount for this transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customFields", - "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).", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "CustomField", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantId", - "description": "The ID of the merchant that processed this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAccountId", - "description": "The ID of the merchant account that processed this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantName", - "description": "The display name of the merchant that processed this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchantAddress", - "description": "The address of the merchant that processed this transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "orderId", - "description": "The order ID for this transaction. For PayPal transactions, the PayPal Invoice ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "purchaseOrderNumber", - "description": "A purchase order identification value you associate with this transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The current status of this transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Fields describing the payment processor response.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionAuthorizationProcessorResponse", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use relevant events in `statusHistory` instead." - }, - { - "name": "riskData", - "description": "Risk data evaluated for this transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "RiskData", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "descriptor", - "description": "Fields used to define what will appear on customers' credit card statements for a specific purchase.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionDescriptor", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "statusHistory", - "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.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "channel", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "How the transaction was created.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customer", - "description": "Customer associated with the transaction, if applicable.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Customer", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "shipping", - "description": "Shipping information.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionShipping", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "tax", - "description": "Tax information.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionTaxInformation", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "scaExemptionRequested", - "description": "The type of Strong Customer Authentication Exemption that was requested for this transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ScaExemptionType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "discountAmount", - "description": "Discount amount that was included in the total transaction amount.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "lineItems", - "description": "Line items for this transaction.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "TransactionLineItem", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "refunds", - "description": "The list of refunds issued against this transaction.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "partialCaptureDetails", - "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.", - "args": [], - "type": { - "kind": "UNION", - "name": "PartialCaptureDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "disputes", - "description": "A collection of disputes associated with the transaction.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Dispute", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "facilitatorDetails", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "FacilitatorDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "disbursementDetails", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "DisbursementDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAddress", - "description": "The billing address associated with the transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authorizationAdjustments", - "description": "A collection of AuthorizationAdjustments associated with the transaction.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "AuthorizationAdjustment", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "retried", - "description": "Whether or not the transaction was automatically retried by Braintree's internal systems.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "installmentDetails", - "description": "Installment details associated with the transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "TransactionInstallmentDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentInitiatedAt", - "description": "The transaction date and time as reported by the in-store payment terminal.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - }, - { - "kind": "INTERFACE", - "name": "Payment", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionAuthorizationAdjustmentProcessorResponse", - "description": "Record of processor response data received in response to authorization adjustment requests.", - "fields": [ - { - "name": "legacyCode", - "description": "The [processor response code](https://developers.braintreepayments.com/reference/general/processor-responses/authorization-responses) indicating the result of attempting the adjustment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "message", - "description": "The text explanation of the processor response code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "declineType", - "description": "Whether or not the decline is the result of a temporary issue. Only present if adjustment is declined.", - "args": [], - "type": { - "kind": "ENUM", - "name": "ProcessorDeclineType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionAuthorizationProcessorResponse", - "description": "Detailed response information from the processor when attempting to authorize a transaction.", - "fields": [ - { - "name": "legacyCode", - "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).", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "message", - "description": "The text explanation of the processor response legacyCode.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cvvResponse", - "description": "The processing bank's response to the provided CVV.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "avsPostalCodeResponse", - "description": "The processing bank's response to the provided billing postal or zip code.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "avsStreetAddressResponse", - "description": "The processing bank's response to the provided billing street address.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "authorizationId", - "description": "The processor's unique ID or \"code\" for the authorization.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "additionalInformation", - "description": "If present, any additional information recieved from the processor. May provide further insight into the `legacyCode`.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "retrievalReferenceNumber", - "description": "The processor's reference number for the authorization.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "emvData", - "description": "Response EMV data provided by the processor if this was an EMV transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionConnection", - "description": "A paginated list of transactions.", - "fields": [ - { - "name": "edges", - "description": "A list of transactions.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "TransactionConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of transactions contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionConnectionEdge", - "description": "A transaction within a TransactionConnection.", - "fields": [ - { - "name": "cursor", - "description": "This transaction's location within the TransactionConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The transaction.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionCustomerDetailsInput", - "description": "Customer details to be stored on the transaction itself, if the transaction is not associated with a customer. Used for fraud detection purposes.", - "fields": null, - "inputFields": [ - { - "name": "email", - "description": "Email address for the customer.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "Phone number for the customer.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionDescriptor", - "description": "Fields used to define what will appear on a customer's bank statement for a specific purchase.", - "fields": [ - { - "name": "name", - "description": "The value in the business name field of a customer's statement.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "phone", - "description": "The value in the phone number field of a customer's statement.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": "The value in the URL/web address field of a customer's statement.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionDescriptorInput", - "description": "Fields used to define what will appear on a customer's bank statement for a specific purchase.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "The value in the business name field of a customer's statement.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phone", - "description": "The value in the phone number field of a customer's statement.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "url", - "description": "The value in the URL/web address field of a customer's statement.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionExternalVaultOptionsInput", - "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.", - "fields": null, - "inputFields": [ - { - "name": "status", - "description": "The credit card's assocation with an external vault.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "ExternalVaultStatus", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "verifyingNetworkTransactionId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionInput", - "description": "Input fields for creating a transaction.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "exchangeRateQuoteId", - "description": "ID of exchange rate quote to be used for the transaction.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "purchaseOrderNumber", - "description": "A purchase order identification value you associate with this transaction.\n\n*Required for Level 2 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "riskData", - "description": "Customer device information, which is sent directly to supported processors for fraud analysis.", - "type": { - "kind": "INPUT_OBJECT", - "name": "RiskDataInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customFields", - "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.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomFieldInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "descriptor", - "description": "Fields used to define what will appear on a customer's bank statement for a specific purchase.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionDescriptorInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "recurring", - "description": "Deprecated: This field is included for supporting legacy clients. Please use `paymentInitiator` instead.", - "type": { - "kind": "ENUM", - "name": "RecurringType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentInitiator", - "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.", - "type": { - "kind": "ENUM", - "name": "PaymentInitiator", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "channel", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shipping", - "description": "Shipping information.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionShippingInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "tax", - "description": "Tax information about the transaction.\n\n*Required for Level 2 processing*.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionTaxInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "discountAmount", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lineItems", - "description": "Line items for this transaction. Up to 249 line items may be specified.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "TransactionLineItemInput", - "ofType": null - } - } - }, - "defaultValue": null - }, - { - "name": "threeDSecurePassThrough", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecurePassThroughInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "vaultPaymentMethodAfterTransacting", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "VaultPaymentMethodAfterTransactingInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerDetails", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "TransactionCustomerDetailsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionInstallment", - "description": "Transaction Installment information.", - "fields": [ - { - "name": "id", - "description": "Installment ID.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "projectedDisbursementDate", - "description": "The projected date for the funds associated with this installment to be disbursed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "actualDisbursementDate", - "description": "The date that the funds associated with this installment were actually disbursed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "Installment amount.The total transaction amount is split equally into each installment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "adjustments", - "description": "List of adjustments associated with the installment.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "TransactionInstallmentAdjustment", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionInstallmentAdjustment", - "description": "Adjustment information.", - "fields": [ - { - "name": "projectedDisbursementDate", - "description": "The projected date for the funds associated with the adjustements to be disbursed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "actualDisbursementDate", - "description": "The date that the funds associated with this adjustments were actually disbursed.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Date", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "Adjustment amount for the installment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": "Transaction Installment Adjustment type.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "TransactionInstallmentAdjustmentType", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "TransactionInstallmentAdjustmentType", - "description": "Transaction Installment Adjustment type to indicate the reason for the adjustment.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "DISPUTE", - "description": "Dispute.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "REFUND", - "description": "Refund.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionInstallmentDetails", - "description": "Installment details for the transaction.", - "fields": [ - { - "name": "count", - "description": "The installment count associated with the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "installments", - "description": "List of installments associated with the transaction.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "TransactionInstallment", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionLevelFeeReport", - "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.", - "fields": [ - { - "name": "url", - "description": "The URL where you can access the requested report.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionLineItem", - "description": "Data for individual line items on a transaction.", - "fields": [ - { - "name": "name", - "description": "Item name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "kind", - "description": "Indicates whether the line item is a sale or refund.", - "args": [], - "type": { - "kind": "ENUM", - "name": "TransactionLineItemType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "quantity", - "description": "Number of units of the item purchased.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "unitAmount", - "description": "Per-unit price of the item.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "totalAmount", - "description": "Total price amount for the line item, i.e. quantity multiplied by unit amount.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "unitTaxAmount", - "description": "Per-unit tax price of the item.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "taxAmount", - "description": "Tax amount for the line item.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "discountAmount", - "description": "The discount amount of the line item.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "unitOfMeasure", - "description": "The unit of measure or the unit of measure code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "productCode", - "description": "Product or UPC code for the item.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "commodityCode", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": "Item description.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": "The URL to product information.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "itemType", - "description": "The type of the line item, i.e., physical, digital etc.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "imageUrl", - "description": "URL to an image that represents the product. Max 1024 characters.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionLineItemInput", - "description": "Data for individual line items on a transaction.", - "fields": null, - "inputFields": [ - { - "name": "name", - "description": "Item name. Maximum 35 characters, or 127 characters for PayPal transactions.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "kind", - "description": "Indicates whether the line item is a sale or refund.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "TransactionLineItemType", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "quantity", - "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*.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "unitAmount", - "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*.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "totalAmount", - "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*.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "unitTaxAmount", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "taxAmount", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "discountAmount", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "unitOfMeasure", - "description": "The unit of measure or the unit of measure code. Maximum 12 characters.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "productCode", - "description": "Product or UPC code for the item. Maximum 12 characters, or 127 characters for PayPal transactions.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "commodityCode", - "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*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "description", - "description": "Item description. Maximum 127 characters.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "url", - "description": "A URL to information about the product.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "itemType", - "description": "The type of the line item, i.e., physical, digital etc.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "imageUrl", - "description": "URL to an image that represents the product. Max 1024 characters.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "TransactionLineItemType", - "description": "Indicates whether a transaction line item is a debit (sale) or credit (refund).", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CREDIT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DEBIT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionPayload", - "description": "Top-level output field from creating a transaction.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "transaction", - "description": "The transaction representing the charge on the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "TransactionReversal", - "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.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "Refund", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "Transaction", - "ofType": null - } - ] - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionSearchInput", - "description": "Input fields for searching for transactions.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find transactions with an ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "status", - "description": "Find transactions with a given transaction status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionStatusInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "statusTransition", - "description": "Find transactions based on the given transaction status transition times.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionStatusTransitionInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "createdAt", - "description": "Find transactions based on the time they were created.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "Find transactions for a given amount or currency.", - "type": { - "kind": "INPUT_OBJECT", - "name": "MonetaryAmountSearchInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "description": "Find transactions with a given orderId.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "Find payments processed through a merchant account ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customer", - "description": "Find transactions with a given customer.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentCustomerInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodSnapshotType", - "description": "Find transactions created by charging payment methods of the given type.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentMethodSnapshotTypeInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "disbursementDate", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchDateInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "source", - "description": "Find transactions created with a given transaction source.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTransactionSourceInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "settlementBatchId", - "description": "Find transactions by the batch ID under which the transaction was submitted for settlement.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTextInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethod", - "description": "Find transactions based on information about the payment method used for the transaction.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchPaymentPaymentMethodInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "facilitatorOAuthApplicationClientId", - "description": "Find transactions created by a third party via the Grant API using a given OAuth application client ID.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "userId", - "description": "Find transactions with a user ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "storeId", - "description": "Find transactions by the ID of the store that the transaction was processed in.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionSettlementProcessorResponse", - "description": "Detailed response information from the processor when attempting to settle a transaction.", - "fields": [ - { - "name": "legacyCode", - "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).", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "message", - "description": "The text explanation of the processor response legacyCode.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cvvResponse", - "description": "The processing bank's response to the provided CVV.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": true, - "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." - }, - { - "name": "avsPostalCodeResponse", - "description": "The processing bank's response to the provided billing postal or zip code.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": true, - "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." - }, - { - "name": "avsStreetAddressResponse", - "description": "The processing bank's response to the provided billing street address.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": true, - "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." - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionShipping", - "description": "Information related to shipping a physical product.", - "fields": [ - { - "name": "shippingAddress", - "description": "Shipping address information.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "shippingAmount", - "description": "The shipping cost of the entire transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "shipsFromPostalCode", - "description": "The postal code of the source shipping location.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionShippingInput", - "description": "Information related to shipping a physical product.", - "fields": null, - "inputFields": [ - { - "name": "shippingAddress", - "description": "Shipping destination address information.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingAmount", - "description": "Shipping cost on the entire transaction.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shipsFromPostalCode", - "description": "The postal code of the source shipping location, in any country's format.\n\n*Required for Level 3 processing*.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "TransactionTaxInformation", - "description": "Information related to taxes on the transaction.", - "fields": [ - { - "name": "taxAmount", - "description": "The amount of tax that was included in the total transaction amount.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "taxExempt", - "description": "Whether the transaction should be considered eligible for tax exemption.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "TransactionTaxInput", - "description": "Information related to taxes on the transaction.", - "fields": null, - "inputFields": [ - { - "name": "taxAmount", - "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`.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "taxExempt", - "description": "Whether the transaction should be considered eligible for tax exemption.\n\n*Required for Level 2 processing*.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "URL", - "description": "A URL string\npattern: [a-zA-Z0-9+-.]:\\\\/\\\\/([-a-zA-Z0-9@:%._\\\\+~#=]{2,256}\\\\.?[a-z]{2,4}\\\\b([-a-zA-Z0-9@:%_\\\\+.~#?&//=]*))?", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UnionPayConfiguration", - "description": "Configuration for UnionPay cards.", - "fields": [ - { - "name": "merchantAccountId", - "description": "The Braintree merchant account ID with UnionPay processing enabled.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UpdateCreditCardBillingAddressInput", - "description": "Top-level input fields for updating a multi-use credit card to use a new billing address.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "The multi-use credit card for which the billing address will be updated or added.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "description": "The new billing address.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the merchant account that will be used when verifying the credit card with the new billing address.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "verification", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CreditCardVerificationOptionsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UpdateCreditCardBillingAddressPayload", - "description": "Top-level fields returned when updating a multi-use credit card to a new billing address.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAddress", - "description": "The new billing address. Will be `null` if a failed verification prevented the update.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verification", - "description": "The verification that was run on the payment method prior to updating the billing address, if present.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Verification", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UpdateCustomerInput", - "description": "Top-level field for updating a customer.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "ID of the customer to be updated.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "customer", - "description": "Input fields for the updates to be made on the customer.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CustomerInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UpdateCustomerPayload", - "description": "Top-level fields returned when updating a customer.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customer", - "description": "Information about the customer that was updated.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Customer", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UpdateInStoreLocationInput", - "description": "Input fields for updating an in-store location.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locationId", - "description": "ID of the location to be updated.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "location", - "description": "Input fields to update an in-store location.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "InStoreLocationUpdateInput", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UpdateInStoreLocationPayload", - "description": "Top-level fields returned when creating an in-store location.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "location", - "description": "The in-store location.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "InStoreLocation", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UpdateInStoreReaderInput", - "description": "Input fields for updating an in-store reader.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "readerId", - "description": "The ID of the in-store reader to update.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "name", - "description": "The new name for the in-store reader.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "locationId", - "description": "The new location ID for the in-store reader.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UpdateTransactionAmountInput", - "description": "Top-level input fields for a updating a transaction's amount.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "ID of the transaction on which to perform the adjustment.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "amount", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UpdateTransactionCustomFieldsInput", - "description": "Input for creating or updating custom fields on a transaction.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "transactionId", - "description": "The ID of the transaction to update.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "customFields", - "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.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "INPUT_OBJECT", - "name": "CustomFieldInput", - "ofType": null - } - } - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UpdateTransactionCustomFieldsPayload", - "description": "Top-level output field from updating custom fields for a specific transaction.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "customFields", - "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).", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "CustomField", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UsBankAccountAchMandate", - "description": "Details about the customer's acceptance of ACH terms.", - "fields": [ - { - "name": "acceptanceText", - "description": "The text the customer agreed to when setting up ACH.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "acceptedAt", - "description": "Date and time when the text terms were accepted.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountBillingAddressInput", - "description": "A billing address for a US bank account. This is a subset of the fields required on `AddressInput`.", - "fields": null, - "inputFields": [ - { - "name": "streetAddress", - "description": "The street address.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "extendedAddress", - "description": "The extended address information—such as an apartment or suite number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "city", - "description": "The city.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "state", - "description": "The state.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UsStateCode", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "zipCode", - "description": "The ZIP code.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "UsZipCode", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountBusinessOwnerInput", - "description": "The name of the owner of a business US bank account.", - "fields": null, - "inputFields": [ - { - "name": "businessName", - "description": "The name of the business that owns the account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UsBankAccountConfiguration", - "description": "Configuration for US bank account processing.", - "fields": [ - { - "name": "routeId", - "description": "The route ID used to process a US bank account payment.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "plaidPublicKey", - "description": "The public key for Plaid to use to log in to a bank account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UsBankAccountDetails", - "description": "Details about a US bank account.", - "fields": [ - { - "name": "accountholderName", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "accountType", - "description": "The bank account type.", - "args": [], - "type": { - "kind": "ENUM", - "name": "UsBankAccountType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ownershipType", - "description": "The ownership type of the account, i.e. business or personal.", - "args": [], - "type": { - "kind": "ENUM", - "name": "UsBankAccountOwnershipType", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bankName", - "description": "The name of the bank at which the account exists.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "last4", - "description": "The last four digits of the bank account number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "routingNumber", - "description": "The routing number of the bank.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verified", - "description": "Whether or not the bank account has been verified and can be transacted on.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "achMandate", - "description": "NACHA-mandated proof of acceptance of ACH terms.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "UsBankAccountAchMandate", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountIndividualOwnerInput", - "description": "The name of the owner of a personal US bank account.", - "fields": null, - "inputFields": [ - { - "name": "firstName", - "description": "The first name of the accountholder.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "lastName", - "description": "The last name of the accountholder.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountInput", - "description": "Input fields for a US bank account object.", - "fields": null, - "inputFields": [ - { - "name": "accountNumber", - "description": "The account number of the bank account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "UsBankAccountNumber", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "routingNumber", - "description": "The routing number of the bank that holds the account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "UsBankRoutingNumber", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "accountType", - "description": "The type of account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UsBankAccountType", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "businessOwner", - "description": "Information about the business that owns the account. This should only be specified for business accounts.", - "type": { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountBusinessOwnerInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "individualOwner", - "description": "Information about the individual that owns the account. This should only be specified for individual accounts.", - "type": { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountIndividualOwnerInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "description": "The billing address of the account.", - "type": { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountBillingAddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "achMandate", - "description": "Language used to prove that you have the customer's explicit permission to debit their bank account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "UsBankAccountNumber", - "description": "An account number containing 1-17 digits.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "UsBankAccountOwnershipType", - "description": "The ownership type of US Bank Account.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "BUSINESS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PERSONAL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "UsBankAccountType", - "description": "The type of US Bank Account.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "CHECKING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SAVINGS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNKNOWN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "UsBankAccountVerificationDetails", - "description": "Information specific to verifications of US bank account payment methods.", - "fields": [ - { - "name": "method", - "description": "Type of US bank account verification performed.", - "args": [], - "type": { - "kind": "ENUM", - "name": "UsBankAccountVerificationMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verificationDeterminedAt", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "UsBankAccountVerificationMethod", - "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).", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "INDEPENDENT_CHECK", - "description": "Verification conducted independently by the merchant, not through Braintree.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MICRO_TRANSFERS", - "description": "Verification by micro-deposits transferred to the bank account, which the customer must then confirm. The most reliable method, but takes additional time.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NETWORK_CHECK", - "description": "Verification via account information. Will complete the verification process immediately, but is not supported by all banks.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TOKENIZED_CHECK", - "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.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "UsBankLoginInput", - "description": "Input fields for a US bank login object.", - "fields": null, - "inputFields": [ - { - "name": "publicToken", - "description": "The public token returned from the bank login.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "accountId", - "description": "The login provider account ID used for the bank login.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "accountType", - "description": "The type of account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UsBankAccountType", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "businessOwner", - "description": "Information about the business that owns the account. This should only be specified for business accounts.", - "type": { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountBusinessOwnerInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "individualOwner", - "description": "Information about the individual that owns the account. This should only be specified for individual accounts.", - "type": { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountIndividualOwnerInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "description": "The billing address of the account.", - "type": { - "kind": "INPUT_OBJECT", - "name": "UsBankAccountBillingAddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "achMandate", - "description": "Language used to prove that you have the customer's explicit permission to debit their bank account.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "UsBankRoutingNumber", - "description": "A routing number containing 8 or 9 digits.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "UsStateCode", - "description": "A two-letter code representing a US state or territory.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "AK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "AZ", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "CT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DC", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GU", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "HI", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ID", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IL", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "IN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "KS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "KY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ME", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MI", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MO", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MP", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MS", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NC", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ND", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NJ", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NM", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NV", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OH", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OK", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PR", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "RI", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SC", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SD", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TN", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "TX", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UM", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VI", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VT", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WA", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WI", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WV", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "WY", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "UsZipCode", - "description": "A US ZIP code. Supports DDDDD and DDDDD-DDDD formats.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "User", - "description": "Details about the user.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "email", - "description": "Email address.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "Current status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "UserStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "Full name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "roles", - "description": "Associated roles.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Role", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "UserStatus", - "description": "The status of the user.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ACTIVE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "DELETED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PASSIVE", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PENDING", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUSPENDED", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultCreditCardExternalVaultOptionsInput", - "description": "Options used to indicate when a credit card is externally vaulted.", - "fields": null, - "inputFields": [ - { - "name": "verifyingNetworkTransactionId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultCreditCardInput", - "description": "Top-level input field for vaulting a credit card so it can be used multiple times.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of an existing single-use credit card payment method to be vaulted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "verification", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "VaultCreditCardVerificationOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "externalVault", - "description": "Options used to indicate when a credit card is externally vaulted.", - "type": { - "kind": "INPUT_OBJECT", - "name": "VaultCreditCardExternalVaultOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "ID of the customer to associate the resulting multi-use payment method with.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "accountType", - "description": "The type of account to be used when verifying a combo card.", - "type": { - "kind": "ENUM", - "name": "CardAccountType", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "threeDSecurePassThrough", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecurePassThroughInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "riskData", - "description": "Customer device information, which is sent directly to supported processors for fraud analysis.", - "type": { - "kind": "INPUT_OBJECT", - "name": "RiskDataInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultCreditCardVerificationOptionsInput", - "description": "Input fields that specify options for verifying the vaulted credit card.", - "fields": null, - "inputFields": [ - { - "name": "merchantAccountId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "skip", - "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.", - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "amount", - "description": "The amount to use to verify the credit card.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "fraudTools", - "description": "Control which fraud tools will be applied to this transaction. Fraud tools cannot be retroactively applied to a transaction if skipped.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CreditCardFraudToolsOptionsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultInStorePaymentMethodAfterTransactingInput", - "description": " Specifies behavior for vaulting a single-use payment method for an in-store transaction.", - "fields": null, - "inputFields": [ - { - "name": "when", - "description": "Specifies the criteria which must be met to vault this payment method.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "VaultPaymentMethodCriteria", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "qrcOverride", - "description": "Vaulting behavior override for QR code payments.", - "type": { - "kind": "ENUM", - "name": "VaultQRCOverride", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultLimitedUsePayPalAccountOptionsInput", - "description": "Input fields that provide information about the resulting PayPal account.", - "fields": null, - "inputFields": [ - { - "name": "amount", - "description": "The total amount of the order. This will be the limit to how much may be captured on the resulting payment method.", - "type": { - "kind": "SCALAR", - "name": "Amount", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customField", - "description": "Variable passed directly to PayPal for your own tracking purposes. Customers do not see this value.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "description", - "description": "Description of the transaction that is displayed to customers in PayPal email receipts.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "orderId", - "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.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingAddress", - "description": "Shipping destination address information.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultPayPalBillingAgreementInput", - "description": "Top-level input fields for importing and vaulting a PayPal Billing Agreement.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAgreementId", - "description": "ID of a PayPal Billing Agreement, that was not created through Braintree, to import and vault.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "Optional ID of the customer to associate the resulting payment method with.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "indirectPayee", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "PayPalAccountInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VaultPayPalBillingAgreementPayload", - "description": "Top-level fields returned when importing and vaulting a PayPal Billing Agreement.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "The vaulted payment method containing the imported PayPal Billing Agreement.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultPaymentMethodAfterTransactingInput", - "description": " Specifies behavior for vaulting a single-use payment method after transacting with it.", - "fields": null, - "inputFields": [ - { - "name": "when", - "description": "Specifies the criteria which must be met to vault this payment method.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "VaultPaymentMethodCriteria", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "VaultPaymentMethodCriteria", - "description": "Defines criteria for vaulting a single-use payment method after transacting with it.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "ALWAYS", - "description": "Always store the single-use payment method after transacting, regardless of the status of the transaction.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ON_SUCCESSFUL_TRANSACTION", - "description": "Only store the single-use payment method if it was successfully authorized.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultPaymentMethodInput", - "description": "Top-level input field for vaulting a payment method so it can be used multiple times.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of an existing single-use payment method to be vaulted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "verificationMerchantAccountId", - "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.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "verification", - "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`.", - "type": { - "kind": "INPUT_OBJECT", - "name": "PaymentMethodVerificationOptionsInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "ID of the customer to associate the resulting multi-use payment method with.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "threeDSecurePassThrough", - "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.", - "type": { - "kind": "INPUT_OBJECT", - "name": "ThreeDSecurePassThroughInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "riskData", - "description": "Customer device information, which is sent directly to supported processors for fraud analysis.", - "type": { - "kind": "INPUT_OBJECT", - "name": "RiskDataInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VaultPaymentMethodPayload", - "description": "Top-level output field from vaulting a payment method.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "description": "A payment method that has been stored in a merchant's vault and can be reused.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verification", - "description": "The verification that was run on the payment method prior to vaulting.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Verification", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "VaultQRCOverride", - "description": "The override options for QR code vaulting.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "HIDE_QRC", - "description": "Do not show QR code as a payment option, even if it is enabled.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SHOW_QRC_NO_VAULT", - "description": "If QR codes are enabled, show as a payment option, but do not vault.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VaultUsBankAccountInput", - "description": "Top-level input field for vaulting a bank account so it can be used multiple times.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of an existing single-use payment method to be vaulted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "verificationMerchantAccountId", - "description": "ID of the merchant account to use when verifying the payment method.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "customerId", - "description": "ID of the customer to associate the resulting multi-use payment method with.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "verificationMethod", - "description": "Type of US bank account verification to perform.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UsBankAccountVerificationMethod", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VenmoAccountDetails", - "description": "Details about a Venmo Account.", - "fields": [ - { - "name": "username", - "description": "The Venmo username, as chosen by the user.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "venmoUserId", - "description": "The Venmo user ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VenmoConfiguration", - "description": "Configuration for Pay with Venmo.", - "fields": [ - { - "name": "merchantId", - "description": "The Venmo merchant ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "accessToken", - "description": "Authorization to use when tokenizing a Venmo payment method.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "environment", - "description": "The Venmo environment.", - "args": [], - "type": { - "kind": "ENUM", - "name": "VenmoEnvironment", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "VenmoEnvironment", - "description": "The environment being used for Venmo.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "PRODUCTION", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SANDBOX", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "production", - "description": null, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sandbox", - "description": null, - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VenmoPayerInfo", - "description": "Information about a payer's Venmo account.", - "fields": [ - { - "name": "firstName", - "description": "The payer's first name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "lastName", - "description": "The payer's last name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "phoneNumber", - "description": "The payer's phone number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "email", - "description": "The payer's email address.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "EmailAddress", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "externalId", - "description": "The external ID of the payer's Venmo account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "userName", - "description": "The username of the payer's Venmo account.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "billingAddress", - "description": "The payer's billing address.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "shippingAddress", - "description": "The payer's shipping address.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Address", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VenmoPayerInfoInput", - "description": "Information about a payer's Venmo account.", - "fields": null, - "inputFields": [ - { - "name": "firstName", - "description": "The payer's first name.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "lastName", - "description": "The payer's last name.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "phoneNumber", - "description": "The payer's phone number.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "email", - "description": "The payer's email address.", - "type": { - "kind": "SCALAR", - "name": "EmailAddress", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "externalId", - "description": "The external ID of the payer's Venmo account.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "userName", - "description": "The username of the payer's Venmo account.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "billingAddress", - "description": "The payer's billing address.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "shippingAddress", - "description": "The payer's shipping address.", - "type": { - "kind": "INPUT_OBJECT", - "name": "AddressInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Verification", - "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.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "legacyId", - "description": "Legacy unique identifier.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethodSnapshot", - "description": "Snapshot of payment method details that were verified. This will always be present.", - "args": [], - "type": { - "kind": "UNION", - "name": "PaymentMethodSnapshot", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethod", - "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.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentMethod", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "For a credit card, the amount used when performing the verification.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": true, - "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." - }, - { - "name": "merchantAccountId", - "description": "The merchant account used for the verification.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "status", - "description": "The current status of this verification, indicating whether the verification was successful. Braintree recommends only vaulting payment methods that are successfully verified.", - "args": [], - "type": { - "kind": "ENUM", - "name": "VerificationStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "processorResponse", - "description": "Detailed response information from the processor. Will not be present if the verification was rejected prior to contacting the processor.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "VerificationProcessorResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "networkResponse", - "description": "Fields describing the network response to the verification request.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "PaymentNetworkResponse", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "createdAt", - "description": "Date and time at which the verification was created.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "gatewayRejectionReason", - "description": "The reason the verification was rejected. This will only be set if status is GATEWAY_REJECTED.", - "args": [], - "type": { - "kind": "ENUM", - "name": "GatewayRejectionReason", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "riskData", - "description": "Risk data evaluated for this verification.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "RiskData", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "paymentMethodVerificationDetails", - "description": "Details unique to the verification based on payment method type being verified.", - "args": [], - "type": { - "kind": "UNION", - "name": "VerificationDetails", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VerificationConnection", - "description": "A paginated list of verifications.", - "fields": [ - { - "name": "edges", - "description": "A list of verifications.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "VerificationConnectionEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information about the page of verifications contained in `edges`.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VerificationConnectionEdge", - "description": "A verification within a VerificationConnection.", - "fields": [ - { - "name": "cursor", - "description": "The verification's location within the VerificationConnection. Used for requesting additional pages.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The verification.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Verification", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "UNION", - "name": "VerificationDetails", - "description": "A union of all possible verification details specific to the type of payment method being verified.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": [ - { - "kind": "OBJECT", - "name": "UsBankAccountVerificationDetails", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "CreditCardVerificationDetails", - "ofType": null - } - ] - }, - { - "kind": "OBJECT", - "name": "VerificationProcessorResponse", - "description": "Detailed response information from the processor.", - "fields": [ - { - "name": "legacyCode", - "description": "The [processor response code](https://developers.braintreepayments.com/reference/general/processor-responses/authorization-responses) indicating the result of attempting the verification.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "message", - "description": "The text explanation of the processor response code.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "cvvResponse", - "description": "The processing bank's response to the provided CVV.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "avsPostalCodeResponse", - "description": "The processing bank's response to the provided billing postal or zip code.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "avsStreetAddressResponse", - "description": "The processing bank's response to the provided billing street address.", - "args": [], - "type": { - "kind": "ENUM", - "name": "AvsCvvResponseCode", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "additionalInformation", - "description": "If present, any additional information recieved from the processor. May provide further insight into the `legacyCode`.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VerificationSearchInput", - "description": "Input fields for searching for verifications.", - "fields": null, - "inputFields": [ - { - "name": "id", - "description": "Find verifications with an ID or IDs.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchValueInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "status", - "description": "Find verifications with a given status.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchVerificationStatusInput", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "createdAt", - "description": "Find verifications with a given created at time.", - "type": { - "kind": "INPUT_OBJECT", - "name": "SearchTimestampInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "VerificationStatus", - "description": "The status of the verification, indicating whether the payment method was successfully verified. Braintree recommends only vaulting payment methods with successful verifications.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "FAILED", - "description": "Indicates the verification was unsuccessful because of an issue communicating with the processor.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "GATEWAY_REJECTED", - "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.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PENDING", - "description": "Indicates that the verification is pending.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "PROCESSOR_DECLINED", - "description": "Indicates that the verification was unsuccessful based on the response from the processor.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VERIFIED", - "description": "Indicates that the verification was successful.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VERIFYING", - "description": "Indicates that the verification is in the process of verifying.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VerifoneVendor", - "description": "Verifone specific in-store reader information.", - "fields": [ - { - "name": "model", - "description": "Model name or number of reader.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "osVersion", - "description": "Current OS version running on the reader.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "serialNumber", - "description": "Vendor-specific device serial number.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VerifyCreditCardInput", - "description": "Top-level input field for verifying a multi-use credit card.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of an existing multi-use payment method to be vaulted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the merchant account to use when verifying the credit card.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "options", - "description": "Input fields for verifying a credit card.", - "type": { - "kind": "INPUT_OBJECT", - "name": "CreditCardVerificationOptionsInput", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VerifyPaymentMethodInput", - "description": "Top-level input field for verifying a multi-use payment method.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of an existing multi-use payment method to be verified.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the merchant account to use when verifying the payment method.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VerifyPaymentMethodPayload", - "description": "Top-level output field from verifying a payment method.", - "fields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "verification", - "description": "The verification that was run on the payment method.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Verification", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "INPUT_OBJECT", - "name": "VerifyUsBankAccountInput", - "description": "Top-level input field for retrying a verification on a bank account.", - "fields": null, - "inputFields": [ - { - "name": "clientMutationId", - "description": "An identifier used to reconcile requests and responses. 255 characters maximum.", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "paymentMethodId", - "description": "ID of an existing multi-use payment method to be vaulted.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - }, - "defaultValue": null - }, - { - "name": "merchantAccountId", - "description": "ID of the merchant account to use when verifying the payment method.", - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "defaultValue": null - }, - { - "name": "verificationMethod", - "description": "Type of US bank account verification to perform.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UsBankAccountVerificationMethod", - "ofType": null - } - }, - "defaultValue": null - } - ], - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "Viewer", - "description": "Details about the user and merchant authenticated in this request.", - "fields": [ - { - "name": "id", - "description": "Unique identifier.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `user` for id instead." - }, - { - "name": "email", - "description": "Email address.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `user` for email instead." - }, - { - "name": "status", - "description": "Current status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "UserStatus", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `user` for status instead." - }, - { - "name": "name", - "description": "Full name.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `user` for name instead." - }, - { - "name": "roles", - "description": "Associated roles.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Role", - "ofType": null - } - } - }, - "isDeprecated": true, - "deprecationReason": "Use `user` for roles instead." - }, - { - "name": "user", - "description": "Details about the authenticated user.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "User", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "merchant", - "description": "Details about the authenticated merchant.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "Merchant", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "rights", - "description": "Associated rights based on authentication.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Right", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VisaCheckoutConfiguration", - "description": "Configuration for Visa Checkout.", - "fields": [ - { - "name": "apiKey", - "description": "The Visa Checkout API key.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "encryptionKey", - "description": "The Visa Checkout encryption key.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "externalClientId", - "description": "The Visa Checkout external client ID.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "supportedCardBrands", - "description": "A list of card brands supported by the merchant for Visa Checkout.", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "CreditCardBrandCode", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VisaCheckoutOriginDetails", - "description": "Additional information about the payment method specific to Visa Checkout.", - "fields": [ - { - "name": "callId", - "description": "The Visa assigned identifier for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "bin", - "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.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "VoidedEvent", - "description": "Accompanying information for a transaction that has been voided.", - "fields": [ - { - "name": "status", - "description": "The new status of the transaction.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentStatus", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "timestamp", - "description": "Date and time when the transaction was voided.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Timestamp", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "amount", - "description": "The amount of the voided transaction. This should match the authorization amount.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "MonetaryAmount", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "The source for the transaction change to the new status.", - "args": [], - "type": { - "kind": "ENUM", - "name": "PaymentSource", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "terminal", - "description": "Whether or not this is the final state for the transaction.", - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "PaymentStatusEvent", - "ofType": null - } - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Year", - "description": "A four-digit year.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Directive", - "description": null, - "fields": [ - { - "name": "name", - "description": "The __Directive type represents a Directive that a server supports.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isRepeatable", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "locations", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "__DirectiveLocation", - "ofType": null - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "args", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__InputValue", - "ofType": null - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "onOperation", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `locations`." - }, - { - "name": "onFragment", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `locations`." - }, - { - "name": "onField", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": true, - "deprecationReason": "Use `locations`." - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "__DirectiveLocation", - "description": "An enum describing valid locations where a directive can be placed", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "QUERY", - "description": "Indicates the directive is valid on queries.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "MUTATION", - "description": "Indicates the directive is valid on mutations.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SUBSCRIPTION", - "description": "Indicates the directive is valid on subscriptions.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FIELD", - "description": "Indicates the directive is valid on fields.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAGMENT_DEFINITION", - "description": "Indicates the directive is valid on fragment definitions.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FRAGMENT_SPREAD", - "description": "Indicates the directive is valid on fragment spreads.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INLINE_FRAGMENT", - "description": "Indicates the directive is valid on inline fragments.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "VARIABLE_DEFINITION", - "description": "Indicates the directive is valid on variable definitions.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SCHEMA", - "description": "Indicates the directive is valid on a schema SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "SCALAR", - "description": "Indicates the directive is valid on a scalar SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OBJECT", - "description": "Indicates the directive is valid on an object SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "FIELD_DEFINITION", - "description": "Indicates the directive is valid on a field SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ARGUMENT_DEFINITION", - "description": "Indicates the directive is valid on a field argument SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INTERFACE", - "description": "Indicates the directive is valid on an interface SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNION", - "description": "Indicates the directive is valid on an union SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ENUM", - "description": "Indicates the directive is valid on an enum SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ENUM_VALUE", - "description": "Indicates the directive is valid on an enum value SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INPUT_OBJECT", - "description": "Indicates the directive is valid on an input object SDL definition.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INPUT_FIELD_DEFINITION", - "description": "Indicates the directive is valid on an input object field SDL definition.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__EnumValue", - "description": null, - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDeprecated", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deprecationReason", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Field", - "description": null, - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "args", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": "false" - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__InputValue", - "ofType": null - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDeprecated", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deprecationReason", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__InputValue", - "description": null, - "fields": [ - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "type", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "defaultValue", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "isDeprecated", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "deprecationReason", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Schema", - "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.", - "fields": [ - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "types", - "description": "A list of all types supported by this server.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "queryType", - "description": "The type that query operations will be rooted at.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "mutationType", - "description": "If this server supports mutation, the type that mutation operations will be rooted at.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "directives", - "description": "'A list of all directives supported by this server.", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Directive", - "ofType": null - } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "subscriptionType", - "description": "'If this server support subscription, the type that subscription operations will be rooted at.", - "args": [], - "type": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "__Type", - "description": null, - "fields": [ - { - "name": "kind", - "description": null, - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "__TypeKind", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "description", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "fields", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": "false" - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Field", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "interfaces", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "possibleTypes", - "description": null, - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "enumValues", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": "false" - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__EnumValue", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "inputFields", - "description": null, - "args": [ - { - "name": "includeDeprecated", - "description": null, - "type": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - }, - "defaultValue": "false" - } - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "__InputValue", - "ofType": null - } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ofType", - "description": null, - "args": [], - "type": { - "kind": "OBJECT", - "name": "__Type", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "specifiedByUrl", - "description": null, - "args": [], - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "ENUM", - "name": "__TypeKind", - "description": "An enum describing what kind of type a given __Type is", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { - "name": "SCALAR", - "description": "Indicates this type is a scalar. 'specifiedByUrl' is a valid field", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "OBJECT", - "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INTERFACE", - "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "UNION", - "description": "Indicates this type is a union. `possibleTypes` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "ENUM", - "description": "Indicates this type is an enum. `enumValues` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "INPUT_OBJECT", - "description": "Indicates this type is an input object. `inputFields` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "LIST", - "description": "Indicates this type is a list. `ofType` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "NON_NULL", - "description": "Indicates this type is a non-null. `ofType` is a valid field.", - "isDeprecated": false, - "deprecationReason": null - } - ], - "possibleTypes": null - } - ], - "directives": [ - { - "name": "include", - "description": "Directs the executor to include this field or fragment only when the `if` argument is true", - "locations": [ - "FIELD", - "FRAGMENT_SPREAD", - "INLINE_FRAGMENT" - ], - "args": [ - { - "name": "if", - "description": "Included when true.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "defaultValue": null - } - ] - }, - { - "name": "skip", - "description": "Directs the executor to skip this field or fragment when the `if`'argument is true.", - "locations": [ - "FIELD", - "FRAGMENT_SPREAD", - "INLINE_FRAGMENT" - ], - "args": [ - { - "name": "if", - "description": "Skipped when true.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - }, - "defaultValue": null - } - ] - }, - { - "name": "deprecated", - "description": "Marks the field, argument, input field or enum value as deprecated", - "locations": [ - "FIELD_DEFINITION", - "ARGUMENT_DEFINITION", - "ENUM_VALUE", - "INPUT_FIELD_DEFINITION" - ], - "args": [ - { - "name": "reason", - "description": "The reason for the deprecation", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - }, - "defaultValue": "\"No longer supported\"" - } - ] - }, - { - "name": "specifiedBy", - "description": "Exposes a URL that specifies the behaviour of this scalar.", - "locations": [ - "SCALAR" - ], - "args": [ - { - "name": "url", - "description": "The URL that specifies the behaviour of this scalar.", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "defaultValue": null - } - ] - } - ] - } - }, - "extensions": { - "requestId" : "1773a68c-af86-410b-aea2-e03390380697" - } -} diff --git a/service/src/main/java-templates/org/whispersystems/textsecuregcm/WhisperServerVersion.java b/service/src/main/java-templates/org/whispersystems/textsecuregcm/WhisperServerVersion.java deleted file mode 100644 index fa4c40bd9..000000000 --- a/service/src/main/java-templates/org/whispersystems/textsecuregcm/WhisperServerVersion.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm; - -public class WhisperServerVersion { - - private static final String VERSION = "${project.version}"; - - public static String getServerVersion() { - return VERSION; - } -} diff --git a/service/src/main/java/org/signal/i18n/HeaderControlledResourceBundleLookup.java b/service/src/main/java/org/signal/i18n/HeaderControlledResourceBundleLookup.java deleted file mode 100644 index 7e2660938..000000000 --- a/service/src/main/java/org/signal/i18n/HeaderControlledResourceBundleLookup.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.i18n; - -import com.google.common.annotations.VisibleForTesting; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.ResourceBundle; -import java.util.ResourceBundle.Control; -import java.util.stream.Collectors; -import javax.annotation.Nonnull; - -public class HeaderControlledResourceBundleLookup { - - private static final int MAX_LOCALES = 15; - - private final ResourceBundleFactory resourceBundleFactory; - - public HeaderControlledResourceBundleLookup() { - this(ResourceBundle::getBundle); - } - - @VisibleForTesting - public HeaderControlledResourceBundleLookup( - @Nonnull final ResourceBundleFactory resourceBundleFactory) { - this.resourceBundleFactory = Objects.requireNonNull(resourceBundleFactory); - } - - @Nonnull - private List getAcceptableLocales(final List acceptableLanguages) { - return acceptableLanguages.stream().limit(MAX_LOCALES).distinct().collect(Collectors.toList()); - } - - @Nonnull - public ResourceBundle getResourceBundle(final String baseName, final List acceptableLocales) { - final List deduplicatedLocales = getAcceptableLocales(acceptableLocales); - final Locale desiredLocale = deduplicatedLocales.isEmpty() ? Locale.getDefault() : deduplicatedLocales.get(0); - // define a control with a fallback order as specified in the header - Control control = new Control() { - @Override - public List getFormats(final String baseName) { - Objects.requireNonNull(baseName); - return Control.FORMAT_PROPERTIES; - } - - @Override - public Locale getFallbackLocale(final String baseName, final Locale locale) { - Objects.requireNonNull(baseName); - if (locale.equals(Locale.getDefault())) { - return null; - } - final int localeIndex = deduplicatedLocales.indexOf(locale); - if (localeIndex < 0 || localeIndex >= deduplicatedLocales.size() - 1) { - return Locale.getDefault(); - } - // [0, deduplicatedLocales.size() - 2] is now the possible range for localeIndex - return deduplicatedLocales.get(localeIndex + 1); - } - }; - - return resourceBundleFactory.createBundle(baseName, desiredLocale, control); - } -} diff --git a/service/src/main/java/org/signal/i18n/ResourceBundleFactory.java b/service/src/main/java/org/signal/i18n/ResourceBundleFactory.java deleted file mode 100644 index 83bc7f283..000000000 --- a/service/src/main/java/org/signal/i18n/ResourceBundleFactory.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.i18n; - -import java.util.Locale; -import java.util.ResourceBundle; - -public interface ResourceBundleFactory { - ResourceBundle createBundle(String baseName, Locale locale, ResourceBundle.Control control); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java deleted file mode 100644 index 30929bdd8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ /dev/null @@ -1,447 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.dropwizard.Configuration; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration; -import org.whispersystems.textsecuregcm.configuration.AccountDatabaseCrawlerConfiguration; -import org.whispersystems.textsecuregcm.configuration.AdminEventLoggingConfiguration; -import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; -import org.whispersystems.textsecuregcm.configuration.AppConfigConfiguration; -import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration; -import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration; -import org.whispersystems.textsecuregcm.configuration.CdnConfiguration; -import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration; -import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration; -import org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration; -import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration; -import org.whispersystems.textsecuregcm.configuration.DynamoDbTables; -import org.whispersystems.textsecuregcm.configuration.FcmConfiguration; -import org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration; -import org.whispersystems.textsecuregcm.configuration.HCaptchaConfiguration; -import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; -import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; -import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; -import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; -import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; -import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; -import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; -import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration; -import org.whispersystems.textsecuregcm.configuration.RedisConfiguration; -import org.whispersystems.textsecuregcm.configuration.RegistrationServiceConfiguration; -import org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration; -import org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration; -import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; -import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; -import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration; -import org.whispersystems.textsecuregcm.configuration.StripeConfiguration; -import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; -import org.whispersystems.textsecuregcm.configuration.TestDeviceConfiguration; -import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration; -import org.whispersystems.textsecuregcm.configuration.ZkConfig; -import org.whispersystems.websocket.configuration.WebSocketConfiguration; - -/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */ -public class WhisperServerConfiguration extends Configuration { - - @NotNull - @Valid - @JsonProperty - private AdminEventLoggingConfiguration adminEventLoggingConfiguration; - - @NotNull - @Valid - @JsonProperty - private StripeConfiguration stripe; - - @NotNull - @Valid - @JsonProperty - private BraintreeConfiguration braintree; - - @NotNull - @Valid - @JsonProperty - private DynamoDbClientConfiguration dynamoDbClientConfiguration; - - @NotNull - @Valid - @JsonProperty - private DynamoDbTables dynamoDbTables; - - @NotNull - @Valid - @JsonProperty - private AwsAttachmentsConfiguration awsAttachments; - - @NotNull - @Valid - @JsonProperty - private GcpAttachmentsConfiguration gcpAttachments; - - @NotNull - @Valid - @JsonProperty - private CdnConfiguration cdn; - - @NotNull - @Valid - @JsonProperty - private DatadogConfiguration datadog; - - @NotNull - @Valid - @JsonProperty - private RedisClusterConfiguration cacheCluster; - - @NotNull - @Valid - @JsonProperty - private RedisConfiguration pubsub; - - @NotNull - @Valid - @JsonProperty - private RedisClusterConfiguration metricsCluster; - - @NotNull - @Valid - @JsonProperty - private DirectoryConfiguration directory; - - @NotNull - @Valid - @JsonProperty - private DirectoryV2Configuration directoryV2; - - @NotNull - @Valid - @JsonProperty - private SecureValueRecovery2Configuration svr2; - - @NotNull - @Valid - @JsonProperty - private AccountDatabaseCrawlerConfiguration accountDatabaseCrawler; - - @NotNull - @Valid - @JsonProperty - private RedisClusterConfiguration pushSchedulerCluster; - - @NotNull - @Valid - @JsonProperty - private RedisClusterConfiguration rateLimitersCluster; - - @NotNull - @Valid - @JsonProperty - private MessageCacheConfiguration messageCache; - - @NotNull - @Valid - @JsonProperty - private RedisClusterConfiguration clientPresenceCluster; - - @Valid - @NotNull - @JsonProperty - private List testDevices = new LinkedList<>(); - - @Valid - @NotNull - @JsonProperty - private List maxDevices = new LinkedList<>(); - - @Valid - @NotNull - @JsonProperty - private RateLimitsConfiguration limits = new RateLimitsConfiguration(); - - @Valid - @NotNull - @JsonProperty - private WebSocketConfiguration webSocket = new WebSocketConfiguration(); - - @Valid - @NotNull - @JsonProperty - private FcmConfiguration fcm; - - @Valid - @NotNull - @JsonProperty - private ApnConfiguration apn; - - @Valid - @NotNull - @JsonProperty - private UnidentifiedDeliveryConfiguration unidentifiedDelivery; - - @Valid - @NotNull - @JsonProperty - private RecaptchaConfiguration recaptcha; - - @Valid - @NotNull - @JsonProperty - private HCaptchaConfiguration hCaptcha; - - @Valid - @NotNull - @JsonProperty - private SecureStorageServiceConfiguration storageService; - - @Valid - @NotNull - @JsonProperty - private SecureBackupServiceConfiguration backupService; - - @Valid - @NotNull - @JsonProperty - private PaymentsServiceConfiguration paymentsService; - - @Valid - @NotNull - @JsonProperty - private ArtServiceConfiguration artService; - - @Valid - @NotNull - @JsonProperty - private ZkConfig zkConfig; - - @Valid - @NotNull - @JsonProperty - private RemoteConfigConfiguration remoteConfig; - - @Valid - @NotNull - @JsonProperty - private AppConfigConfiguration appConfig; - - @Valid - @NotNull - @JsonProperty - private BadgesConfiguration badges; - - @Valid - @JsonProperty - @NotNull - private SubscriptionConfiguration subscription; - - @Valid - @JsonProperty - @NotNull - private OneTimeDonationConfiguration oneTimeDonations; - - @Valid - @NotNull - @JsonProperty - private ReportMessageConfiguration reportMessage = new ReportMessageConfiguration(); - - @Valid - @JsonProperty - private SpamFilterConfiguration spamFilterConfiguration; - - @Valid - @NotNull - @JsonProperty - private RegistrationServiceConfiguration registrationService; - - public AdminEventLoggingConfiguration getAdminEventLoggingConfiguration() { - return adminEventLoggingConfiguration; - } - - public StripeConfiguration getStripe() { - return stripe; - } - - public BraintreeConfiguration getBraintree() { - return braintree; - } - - public DynamoDbClientConfiguration getDynamoDbClientConfiguration() { - return dynamoDbClientConfiguration; - } - - public DynamoDbTables getDynamoDbTables() { - return dynamoDbTables; - } - - public RecaptchaConfiguration getRecaptchaConfiguration() { - return recaptcha; - } - - public HCaptchaConfiguration getHCaptchaConfiguration() { - return hCaptcha; - } - - public WebSocketConfiguration getWebSocketConfiguration() { - return webSocket; - } - - public AwsAttachmentsConfiguration getAwsAttachmentsConfiguration() { - return awsAttachments; - } - - public GcpAttachmentsConfiguration getGcpAttachmentsConfiguration() { - return gcpAttachments; - } - - public RedisClusterConfiguration getCacheClusterConfiguration() { - return cacheCluster; - } - - public RedisConfiguration getPubsubCacheConfiguration() { - return pubsub; - } - - public RedisClusterConfiguration getMetricsClusterConfiguration() { - return metricsCluster; - } - - public DirectoryConfiguration getDirectoryConfiguration() { - return directory; - } - - public SecureValueRecovery2Configuration getSvr2Configuration() { - return svr2; - } - - public DirectoryV2Configuration getDirectoryV2Configuration() { - return directoryV2; - } - - public SecureStorageServiceConfiguration getSecureStorageServiceConfiguration() { - return storageService; - } - - public AccountDatabaseCrawlerConfiguration getAccountDatabaseCrawlerConfiguration() { - return accountDatabaseCrawler; - } - - public MessageCacheConfiguration getMessageCacheConfiguration() { - return messageCache; - } - - public RedisClusterConfiguration getClientPresenceClusterConfiguration() { - return clientPresenceCluster; - } - - public RedisClusterConfiguration getPushSchedulerCluster() { - return pushSchedulerCluster; - } - - public RedisClusterConfiguration getRateLimitersCluster() { - return rateLimitersCluster; - } - - public RateLimitsConfiguration getLimitsConfiguration() { - return limits; - } - - public FcmConfiguration getFcmConfiguration() { - return fcm; - } - - public ApnConfiguration getApnConfiguration() { - return apn; - } - - public CdnConfiguration getCdnConfiguration() { - return cdn; - } - - public DatadogConfiguration getDatadogConfiguration() { - return datadog; - } - - public UnidentifiedDeliveryConfiguration getDeliveryCertificate() { - return unidentifiedDelivery; - } - - public Map getTestDevices() { - Map results = new HashMap<>(); - - for (TestDeviceConfiguration testDeviceConfiguration : testDevices) { - results.put(testDeviceConfiguration.getNumber(), - testDeviceConfiguration.getCode()); - } - - return results; - } - - public Map getMaxDevices() { - Map results = new HashMap<>(); - - for (MaxDeviceConfiguration maxDeviceConfiguration : maxDevices) { - results.put(maxDeviceConfiguration.getNumber(), - maxDeviceConfiguration.getCount()); - } - - return results; - } - - public SecureBackupServiceConfiguration getSecureBackupServiceConfiguration() { - return backupService; - } - - public PaymentsServiceConfiguration getPaymentsServiceConfiguration() { - return paymentsService; - } - - public ArtServiceConfiguration getArtServiceConfiguration() { - return artService; - } - - public ZkConfig getZkConfig() { - return zkConfig; - } - - public RemoteConfigConfiguration getRemoteConfigConfiguration() { - return remoteConfig; - } - - public AppConfigConfiguration getAppConfig() { - return appConfig; - } - - public BadgesConfiguration getBadges() { - return badges; - } - - public SubscriptionConfiguration getSubscription() { - return subscription; - } - - public OneTimeDonationConfiguration getOneTimeDonations() { - return oneTimeDonations; - } - - public ReportMessageConfiguration getReportMessageConfiguration() { - return reportMessage; - } - - public SpamFilterConfiguration getSpamFilterConfiguration() { - return spamFilterConfiguration; - } - - public RegistrationServiceConfiguration getRegistrationServiceConfiguration() { - return registrationService; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java deleted file mode 100644 index 7c5f14f84..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ /dev/null @@ -1,870 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.InstanceProfileCredentialsProvider; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.codahale.metrics.SharedMetricRegistries; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.cloud.logging.LoggingOptions; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Lists; -import io.dropwizard.Application; -import io.dropwizard.auth.AuthFilter; -import io.dropwizard.auth.PolymorphicAuthDynamicFeature; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.auth.basic.BasicCredentialAuthFilter; -import io.dropwizard.auth.basic.BasicCredentials; -import io.dropwizard.setup.Bootstrap; -import io.dropwizard.setup.Environment; -import io.lettuce.core.metrics.MicrometerCommandLatencyRecorder; -import io.lettuce.core.metrics.MicrometerOptions; -import io.lettuce.core.resource.ClientResources; -import io.micrometer.core.instrument.Meter.Id; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import io.micrometer.core.instrument.config.MeterFilter; -import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; -import io.micrometer.datadog.DatadogMeterRegistry; -import java.io.ByteArrayInputStream; -import java.net.http.HttpClient; -import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.EnumSet; -import java.util.List; -import java.util.Optional; -import java.util.ServiceLoader; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import javax.servlet.DispatcherType; -import javax.servlet.FilterRegistration; -import javax.servlet.ServletRegistration; -import org.eclipse.jetty.servlets.CrossOriginFilter; -import org.glassfish.jersey.server.ServerProperties; -import org.signal.event.AdminEventLogger; -import org.signal.event.GoogleCloudAdminEventLogger; -import org.signal.i18n.HeaderControlledResourceBundleLookup; -import org.signal.libsignal.zkgroup.ServerSecretParams; -import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations; -import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; -import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; -import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.dispatch.DispatchManager; -import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.CertificateGenerator; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; -import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; -import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; -import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener; -import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter; -import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator; -import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; -import org.whispersystems.textsecuregcm.captcha.HCaptchaClient; -import org.whispersystems.textsecuregcm.captcha.RecaptchaClient; -import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.controllers.AccountController; -import org.whispersystems.textsecuregcm.controllers.AccountControllerV2; -import org.whispersystems.textsecuregcm.controllers.ArtController; -import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; -import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; -import org.whispersystems.textsecuregcm.controllers.CertificateController; -import org.whispersystems.textsecuregcm.controllers.ChallengeController; -import org.whispersystems.textsecuregcm.controllers.DeviceController; -import org.whispersystems.textsecuregcm.controllers.DirectoryController; -import org.whispersystems.textsecuregcm.controllers.DirectoryV2Controller; -import org.whispersystems.textsecuregcm.controllers.DonationController; -import org.whispersystems.textsecuregcm.controllers.KeepAliveController; -import org.whispersystems.textsecuregcm.controllers.KeysController; -import org.whispersystems.textsecuregcm.controllers.MessageController; -import org.whispersystems.textsecuregcm.controllers.PaymentsController; -import org.whispersystems.textsecuregcm.controllers.ProfileController; -import org.whispersystems.textsecuregcm.controllers.ProvisioningController; -import org.whispersystems.textsecuregcm.controllers.RegistrationController; -import org.whispersystems.textsecuregcm.controllers.RemoteConfigController; -import org.whispersystems.textsecuregcm.controllers.SecureBackupController; -import org.whispersystems.textsecuregcm.controllers.SecureStorageController; -import org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller; -import org.whispersystems.textsecuregcm.controllers.StickerController; -import org.whispersystems.textsecuregcm.controllers.SubscriptionController; -import org.whispersystems.textsecuregcm.currency.CoinMarketCapClient; -import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; -import org.whispersystems.textsecuregcm.currency.FixerClient; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter; -import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter; -import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; -import org.whispersystems.textsecuregcm.limits.DynamicRateLimiters; -import org.whispersystems.textsecuregcm.limits.PushChallengeManager; -import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.IOExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper; -import org.whispersystems.textsecuregcm.metrics.ApplicationShutdownMonitor; -import org.whispersystems.textsecuregcm.metrics.BufferPoolGauges; -import org.whispersystems.textsecuregcm.metrics.CpuUsageGauge; -import org.whispersystems.textsecuregcm.metrics.FileDescriptorGauge; -import org.whispersystems.textsecuregcm.metrics.FreeMemoryGauge; -import org.whispersystems.textsecuregcm.metrics.GarbageCollectionGauges; -import org.whispersystems.textsecuregcm.metrics.LettuceMetricsMeterFilter; -import org.whispersystems.textsecuregcm.metrics.MaxFileDescriptorGauge; -import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener; -import org.whispersystems.textsecuregcm.metrics.MetricsRequestEventListener; -import org.whispersystems.textsecuregcm.metrics.MicrometerRegistryManager; -import org.whispersystems.textsecuregcm.metrics.NetworkReceivedGauge; -import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge; -import org.whispersystems.textsecuregcm.metrics.OperatingSystemMemoryGauge; -import org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener; -import org.whispersystems.textsecuregcm.metrics.TrafficSource; -import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; -import org.whispersystems.textsecuregcm.providers.RedisClientFactory; -import org.whispersystems.textsecuregcm.providers.RedisClusterHealthCheck; -import org.whispersystems.textsecuregcm.push.APNSender; -import org.whispersystems.textsecuregcm.push.ApnPushNotificationScheduler; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.push.FcmSender; -import org.whispersystems.textsecuregcm.push.MessageSender; -import org.whispersystems.textsecuregcm.push.ProvisioningManager; -import org.whispersystems.textsecuregcm.push.PushLatencyManager; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.redis.ConnectionEventLogger; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool; -import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; -import org.whispersystems.textsecuregcm.s3.PolicySigner; -import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.spam.FilterSpam; -import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener; -import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; -import org.whispersystems.textsecuregcm.spam.SpamFilter; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.storage.AccountCleaner; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawler; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerCache; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerListener; -import org.whispersystems.textsecuregcm.storage.Accounts; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; -import org.whispersystems.textsecuregcm.storage.ContactDiscoveryWriter; -import org.whispersystems.textsecuregcm.storage.DeletedAccounts; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsDirectoryReconciler; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsTableCrawler; -import org.whispersystems.textsecuregcm.storage.DirectoryReconciler; -import org.whispersystems.textsecuregcm.storage.DirectoryReconciliationClient; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.storage.MessagePersister; -import org.whispersystems.textsecuregcm.storage.MessagesCache; -import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.NonNormalizedAccountCrawlerListener; -import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; -import org.whispersystems.textsecuregcm.storage.Profiles; -import org.whispersystems.textsecuregcm.storage.ProfilesManager; -import org.whispersystems.textsecuregcm.storage.PubSubManager; -import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb; -import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor; -import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.storage.RemoteConfigs; -import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.storage.SubscriptionManager; -import org.whispersystems.textsecuregcm.storage.VerificationCodeStore; -import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; -import org.whispersystems.textsecuregcm.subscriptions.StripeManager; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig; -import org.whispersystems.textsecuregcm.util.HostnameUtil; -import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier; -import org.whispersystems.textsecuregcm.util.logging.LoggingUnhandledExceptionMapper; -import org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler; -import org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener; -import org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener; -import org.whispersystems.textsecuregcm.websocket.WebSocketAccountAuthenticator; -import org.whispersystems.textsecuregcm.workers.AssignUsernameCommand; -import org.whispersystems.textsecuregcm.workers.CertificateCommand; -import org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand; -import org.whispersystems.textsecuregcm.workers.DeleteUserCommand; -import org.whispersystems.textsecuregcm.workers.ReserveUsernameCommand; -import org.whispersystems.textsecuregcm.workers.ServerVersionCommand; -import org.whispersystems.textsecuregcm.workers.SetCrawlerAccelerationTask; -import org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask; -import org.whispersystems.textsecuregcm.workers.SetUserDiscoverabilityCommand; -import org.whispersystems.textsecuregcm.workers.ZkParamsCommand; -import org.whispersystems.websocket.WebSocketResourceProviderFactory; -import org.whispersystems.websocket.setup.WebSocketEnvironment; -import reactor.core.scheduler.Schedulers; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.s3.S3Client; - -public class WhisperServerService extends Application { - - private static final Logger log = LoggerFactory.getLogger(WhisperServerService.class); - - @Override - public void initialize(Bootstrap bootstrap) { - bootstrap.addCommand(new DeleteUserCommand()); - bootstrap.addCommand(new CertificateCommand()); - bootstrap.addCommand(new ZkParamsCommand()); - bootstrap.addCommand(new ServerVersionCommand()); - bootstrap.addCommand(new CheckDynamicConfigurationCommand()); - bootstrap.addCommand(new SetUserDiscoverabilityCommand()); - bootstrap.addCommand(new ReserveUsernameCommand()); - bootstrap.addCommand(new AssignUsernameCommand()); - } - - @Override - public String getName() { - return "whisper-server"; - } - - @Override - public void run(WhisperServerConfiguration config, Environment environment) throws Exception { - final Clock clock = Clock.systemUTC(); - final int availableProcessors = Runtime.getRuntime().availableProcessors(); - - UncaughtExceptionHandler.register(); - - SharedMetricRegistries.add(Constants.METRICS_NAME, environment.metrics()); - - final DistributionStatisticConfig defaultDistributionStatisticConfig = DistributionStatisticConfig.builder() - .percentiles(.75, .95, .99, .999) - .build(); - - { - final DatadogMeterRegistry datadogMeterRegistry = new DatadogMeterRegistry( - config.getDatadogConfiguration(), io.micrometer.core.instrument.Clock.SYSTEM); - - datadogMeterRegistry.config().commonTags( - Tags.of( - "service", "chat", - "host", HostnameUtil.getLocalHostname(), - "version", WhisperServerVersion.getServerVersion(), - "env", config.getDatadogConfiguration().getEnvironment())) - .meterFilter(MeterFilter.denyNameStartsWith(MetricsRequestEventListener.REQUEST_COUNTER_NAME)) - .meterFilter(MeterFilter.denyNameStartsWith(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME)) - .meterFilter(MeterFilter.denyNameStartsWith(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME)) - .meterFilter(MeterFilter.denyNameStartsWith(MetricsRequestEventListener.IOS_REQUEST_COUNTER_NAME)) - .meterFilter(new LettuceMetricsMeterFilter()) - .meterFilter(new MeterFilter() { - @Override - public DistributionStatisticConfig configure(final Id id, final DistributionStatisticConfig config) { - return defaultDistributionStatisticConfig.merge(config); - } - }); - - Metrics.addRegistry(datadogMeterRegistry); - } - - environment.lifecycle().manage(new MicrometerRegistryManager(Metrics.globalRegistry)); - - environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - environment.getObjectMapper().setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); - environment.getObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); - - HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup = - new HeaderControlledResourceBundleLookup(); - ConfiguredProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter( - clock, config.getBadges(), headerControlledResourceBundleLookup); - ResourceBundleLevelTranslator resourceBundleLevelTranslator = new ResourceBundleLevelTranslator( - headerControlledResourceBundleLookup); - - DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient( - config.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client( - config.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - AmazonDynamoDB deletedAccountsLockDynamoDbClient = AmazonDynamoDBClientBuilder.standard() - .withRegion(config.getDynamoDbClientConfiguration().getRegion()) - .withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout( - ((int) config.getDynamoDbClientConfiguration().getClientExecutionTimeout().toMillis())) - .withRequestTimeout( - (int) config.getDynamoDbClientConfiguration().getClientRequestTimeout().toMillis())) - .withCredentials(InstanceProfileCredentialsProvider.getInstance()) - .build(); - - DeletedAccounts deletedAccounts = new DeletedAccounts(dynamoDbClient, - config.getDynamoDbTables().getDeletedAccounts().getTableName(), - config.getDynamoDbTables().getDeletedAccounts().getNeedsReconciliationIndexName()); - - DynamicConfigurationManager dynamicConfigurationManager = - new DynamicConfigurationManager<>(config.getAppConfig().getApplication(), - config.getAppConfig().getEnvironment(), - config.getAppConfig().getConfigurationName(), - DynamicConfiguration.class); - - BlockingQueue messageDeletionQueue = new LinkedBlockingQueue<>(); - Metrics.gaugeCollectionSize(name(getClass(), "messageDeletionQueueSize"), Collections.emptyList(), - messageDeletionQueue); - ExecutorService messageDeletionAsyncExecutor = environment.lifecycle() - .executorService(name(getClass(), "messageDeletionAsyncExecutor-%d")).maxThreads(16) - .workQueue(messageDeletionQueue).build(); - - Accounts accounts = new Accounts( - dynamoDbClient, - dynamoDbAsyncClient, - config.getDynamoDbTables().getAccounts().getTableName(), - config.getDynamoDbTables().getAccounts().getPhoneNumberTableName(), - config.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(), - config.getDynamoDbTables().getAccounts().getUsernamesTableName(), - config.getDynamoDbTables().getAccounts().getScanPageSize()); - PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient, - config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName()); - Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient, - config.getDynamoDbTables().getProfiles().getTableName()); - Keys keys = new Keys(dynamoDbClient, config.getDynamoDbTables().getKeys().getTableName()); - MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient, - config.getDynamoDbTables().getMessages().getTableName(), - config.getDynamoDbTables().getMessages().getExpiration(), - messageDeletionAsyncExecutor); - RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient, - config.getDynamoDbTables().getRemoteConfig().getTableName()); - PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient, - config.getDynamoDbTables().getPushChallenge().getTableName()); - ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbClient, - config.getDynamoDbTables().getReportMessage().getTableName(), - config.getReportMessageConfiguration().getReportTtl()); - VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient, - config.getDynamoDbTables().getPendingAccounts().getTableName()); - VerificationCodeStore pendingDevices = new VerificationCodeStore(dynamoDbClient, - config.getDynamoDbTables().getPendingDevices().getTableName()); - RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords( - config.getDynamoDbTables().getRegistrationRecovery().getTableName(), - config.getDynamoDbTables().getRegistrationRecovery().getExpiration(), - dynamoDbClient, - dynamoDbAsyncClient - ); - - reactor.util.Metrics.MicrometerConfiguration.useRegistry(Metrics.globalRegistry); - Schedulers.enableMetrics(); - - RedisClientFactory pubSubClientFactory = new RedisClientFactory("pubsub_cache", - config.getPubsubCacheConfiguration().getUrl(), config.getPubsubCacheConfiguration().getReplicaUrls(), - config.getPubsubCacheConfiguration().getCircuitBreakerConfiguration()); - ReplicatedJedisPool pubsubClient = pubSubClientFactory.getRedisClientPool(); - - MicrometerOptions options = MicrometerOptions.builder().build(); - ClientResources redisClientResources = ClientResources.builder() - .commandLatencyRecorder(new MicrometerCommandLatencyRecorder(Metrics.globalRegistry, options)).build(); - ConnectionEventLogger.logConnectionEvents(redisClientResources); - - FaultTolerantRedisCluster cacheCluster = new FaultTolerantRedisCluster("main_cache_cluster", config.getCacheClusterConfiguration(), redisClientResources); - FaultTolerantRedisCluster messagesCluster = new FaultTolerantRedisCluster("messages_cluster", config.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClientResources); - FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence_cluster", config.getClientPresenceClusterConfiguration(), redisClientResources); - FaultTolerantRedisCluster metricsCluster = new FaultTolerantRedisCluster("metrics_cluster", config.getMetricsClusterConfiguration(), redisClientResources); - FaultTolerantRedisCluster pushSchedulerCluster = new FaultTolerantRedisCluster("push_scheduler", config.getPushSchedulerCluster(), redisClientResources); - FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters", config.getRateLimitersCluster(), redisClientResources); - - final BlockingQueue keyspaceNotificationDispatchQueue = new ArrayBlockingQueue<>(100_000); - Metrics.gaugeCollectionSize(name(getClass(), "keyspaceNotificationDispatchQueueSize"), Collections.emptyList(), keyspaceNotificationDispatchQueue); - final BlockingQueue receiptSenderQueue = new LinkedBlockingQueue<>(); - Metrics.gaugeCollectionSize(name(getClass(), "receiptSenderQueue"), Collections.emptyList(), receiptSenderQueue); - - final BlockingQueue fcmSenderQueue = new LinkedBlockingQueue<>(); - Metrics.gaugeCollectionSize(name(getClass(), "fcmSenderQueue"), Collections.emptyList(), fcmSenderQueue); - - ScheduledExecutorService recurringJobExecutor = environment.lifecycle() - .scheduledExecutorService(name(getClass(), "recurringJob-%d")).threads(6).build(); - ScheduledExecutorService websocketScheduledExecutor = environment.lifecycle().scheduledExecutorService(name(getClass(), "websocket-%d")).threads(8).build(); - ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle().executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(16).workQueue(keyspaceNotificationDispatchQueue).build(); - ExecutorService apnSenderExecutor = environment.lifecycle().executorService(name(getClass(), "apnSender-%d")).maxThreads(1).minThreads(1).build(); - ExecutorService fcmSenderExecutor = environment.lifecycle().executorService(name(getClass(), "fcmSender-%d")).maxThreads(32).minThreads(32).workQueue(fcmSenderQueue).build(); - ExecutorService backupServiceExecutor = environment.lifecycle().executorService(name(getClass(), "backupService-%d")).maxThreads(1).minThreads(1).build(); - ExecutorService storageServiceExecutor = environment.lifecycle().executorService(name(getClass(), "storageService-%d")).maxThreads(1).minThreads(1).build(); - ExecutorService accountDeletionExecutor = environment.lifecycle().executorService(name(getClass(), "accountCleaner-%d")).maxThreads(16).minThreads(16).build(); - - // 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 - ExecutorService batchIdentityCheckExecutor = environment.lifecycle().executorService(name(getClass(), "batchIdentityCheck-%d")).minThreads(32).maxThreads(32).build(); - ExecutorService multiRecipientMessageExecutor = environment.lifecycle() - .executorService(name(getClass(), "multiRecipientMessage-%d")).minThreads(64).maxThreads(64).build(); - ExecutorService subscriptionProcessorExecutor = environment.lifecycle() - .executorService(name(getClass(), "subscriptionProcessor-%d")) - .maxThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best - .minThreads(availableProcessors) // mostly this is IO bound so tying to number of processors is tenuous at best - .allowCoreThreadTimeOut(true). - build(); - ExecutorService receiptSenderExecutor = environment.lifecycle() - .executorService(name(getClass(), "receiptSender-%d")) - .maxThreads(2) - .minThreads(2) - .workQueue(receiptSenderQueue) - .rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()) - .build(); - ExecutorService registrationCallbackExecutor = environment.lifecycle() - .executorService(name(getClass(), "registration-%d")) - .maxThreads(2) - .minThreads(2) - .build(); - - final AdminEventLogger adminEventLogger = new GoogleCloudAdminEventLogger( - LoggingOptions.newBuilder().setProjectId(config.getAdminEventLoggingConfiguration().projectId()) - .setCredentials(GoogleCredentials.fromStream(new ByteArrayInputStream( - config.getAdminEventLoggingConfiguration().credentials().getBytes(StandardCharsets.UTF_8)))) - .build().getService(), - config.getAdminEventLoggingConfiguration().projectId(), - config.getAdminEventLoggingConfiguration().logName()); - - StripeManager stripeManager = new StripeManager(config.getStripe().apiKey(), subscriptionProcessorExecutor, - config.getStripe().idempotencyKeyGenerator(), config.getStripe().boostDescription(), config.getStripe() - .supportedCurrencies()); - BraintreeManager braintreeManager = new BraintreeManager(config.getBraintree().merchantId(), - config.getBraintree().publicKey(), config.getBraintree().privateKey(), config.getBraintree().environment(), - config.getBraintree().supportedCurrencies(), config.getBraintree().merchantAccounts(), - config.getBraintree().graphqlUrl(), config.getBraintree().circuitBreaker(), subscriptionProcessorExecutor); - - ExternalServiceCredentialsGenerator directoryCredentialsGenerator = DirectoryController.credentialsGenerator( - config.getDirectoryConfiguration().getDirectoryClientConfiguration()); - ExternalServiceCredentialsGenerator directoryV2CredentialsGenerator = DirectoryV2Controller.credentialsGenerator( - config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration()); - ExternalServiceCredentialsGenerator storageCredentialsGenerator = SecureStorageController.credentialsGenerator( - config.getSecureStorageServiceConfiguration()); - ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator( - config.getSecureBackupServiceConfiguration()); - ExternalServiceCredentialsGenerator paymentsCredentialsGenerator = PaymentsController.credentialsGenerator( - config.getPaymentsServiceConfiguration()); - ExternalServiceCredentialsGenerator artCredentialsGenerator = ArtController.credentialsGenerator( - config.getArtServiceConfiguration()); - ExternalServiceCredentialsGenerator svr2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator( - config.getSvr2Configuration()); - - dynamicConfigurationManager.start(); - - ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); - RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords); - UsernameHashZkProofVerifier usernameHashZkProofVerifier = new UsernameHashZkProofVerifier(); - - RegistrationServiceClient registrationServiceClient = new RegistrationServiceClient(config.getRegistrationServiceConfiguration().getHost(), config.getRegistrationServiceConfiguration().getPort(), config.getRegistrationServiceConfiguration().getApiKey(), config.getRegistrationServiceConfiguration().getRegistrationCaCertificate(), registrationCallbackExecutor); - SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor, config.getSecureBackupServiceConfiguration()); - SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, storageServiceExecutor, config.getSecureStorageServiceConfiguration()); - ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, recurringJobExecutor, keyspaceNotificationDispatchExecutor); - DirectoryQueue directoryQueue = new DirectoryQueue(config.getDirectoryConfiguration().getSqsConfiguration()); - StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts); - StoredVerificationCodeManager pendingDevicesManager = new StoredVerificationCodeManager(pendingDevices); - ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster); - MessagesCache messagesCache = new MessagesCache(messagesCluster, messagesCluster, Clock.systemUTC(), - keyspaceNotificationDispatchExecutor, messageDeletionAsyncExecutor); - PushLatencyManager pushLatencyManager = new PushLatencyManager(metricsCluster, dynamicConfigurationManager); - ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, - config.getReportMessageConfiguration().getCounterTtl()); - MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, - messageDeletionAsyncExecutor); - DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts, - deletedAccountsLockDynamoDbClient, config.getDynamoDbTables().getDeletedAccountsLock().getTableName()); - AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster, - deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager, - pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, - experimentEnrollmentManager, registrationRecoveryPasswordsManager, clock); - RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs); - DispatchManager dispatchManager = new DispatchManager(pubSubClientFactory, Optional.empty()); - PubSubManager pubSubManager = new PubSubManager(pubsubClient, dispatchManager); - APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration()); - FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials()); - ApnPushNotificationScheduler apnPushNotificationScheduler = new ApnPushNotificationScheduler(pushSchedulerCluster, apnSender, accountsManager); - PushNotificationManager pushNotificationManager = new PushNotificationManager(accountsManager, apnSender, fcmSender, apnPushNotificationScheduler, pushLatencyManager, dynamicConfigurationManager); - RateLimiters rateLimiters = new RateLimiters(config.getLimitsConfiguration(), rateLimitersCluster); - DynamicRateLimiters dynamicRateLimiters = new DynamicRateLimiters(rateLimitersCluster, dynamicConfigurationManager); - ProvisioningManager provisioningManager = new ProvisioningManager(pubSubManager); - IssuedReceiptsManager issuedReceiptsManager = new IssuedReceiptsManager( - config.getDynamoDbTables().getIssuedReceipts().getTableName(), - config.getDynamoDbTables().getIssuedReceipts().getExpiration(), - dynamoDbAsyncClient, - config.getDynamoDbTables().getIssuedReceipts().getGenerator()); - RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager( - clock, - config.getDynamoDbTables().getRedeemedReceipts().getTableName(), - dynamoDbAsyncClient, - config.getDynamoDbTables().getRedeemedReceipts().getExpiration()); - SubscriptionManager subscriptionManager = new SubscriptionManager( - config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient); - - final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager( - accountsManager, clientPresenceManager, backupCredentialsGenerator, rateLimiters); - final PhoneVerificationTokenManager phoneVerificationTokenManager = new PhoneVerificationTokenManager( - registrationServiceClient, registrationRecoveryPasswordsManager); - - final ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener( - accountsManager); - reportMessageManager.addListener(reportedMessageMetricsListener); - - final AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager); - final DisabledPermittedAccountAuthenticator disabledPermittedAccountAuthenticator = new DisabledPermittedAccountAuthenticator( - accountsManager); - - final MessageSender messageSender = new MessageSender(clientPresenceManager, messagesManager, - pushNotificationManager, - pushLatencyManager); - final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor); - final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager); - - RecaptchaClient recaptchaClient = new RecaptchaClient( - config.getRecaptchaConfiguration().getProjectPath(), - config.getRecaptchaConfiguration().getCredentialConfigurationJson(), - dynamicConfigurationManager); - HttpClient hcaptchaHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build(); - HCaptchaClient hCaptchaClient = new HCaptchaClient(config.getHCaptchaConfiguration().apiKey(), hcaptchaHttpClient, dynamicConfigurationManager); - CaptchaChecker captchaChecker = new CaptchaChecker(List.of(recaptchaClient, hCaptchaClient)); - - PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager, pushChallengeDynamoDb); - RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager, - captchaChecker, dynamicRateLimiters); - - MessagePersister messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, dynamicConfigurationManager, Duration.ofMinutes(config.getMessageCacheConfiguration().getPersistDelayMinutes())); - ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager); - - final List directoryReconciliationAccountDatabaseCrawlerListeners = new ArrayList<>(); - final List deletedAccountsDirectoryReconcilers = new ArrayList<>(); - for (DirectoryServerConfiguration directoryServerConfiguration : config.getDirectoryConfiguration() - .getDirectoryServerConfiguration()) { - final DirectoryReconciliationClient directoryReconciliationClient = new DirectoryReconciliationClient( - directoryServerConfiguration); - final DirectoryReconciler directoryReconciler = new DirectoryReconciler( - directoryServerConfiguration.getReplicationName(), directoryReconciliationClient, - dynamicConfigurationManager); - // reconcilers are read-only - directoryReconciliationAccountDatabaseCrawlerListeners.add(directoryReconciler); - - final DeletedAccountsDirectoryReconciler deletedAccountsDirectoryReconciler = new DeletedAccountsDirectoryReconciler( - directoryServerConfiguration.getReplicationName(), directoryReconciliationClient); - deletedAccountsDirectoryReconcilers.add(deletedAccountsDirectoryReconciler); - } - - AccountDatabaseCrawlerCache directoryReconciliationAccountDatabaseCrawlerCache = new AccountDatabaseCrawlerCache( - cacheCluster, AccountDatabaseCrawlerCache.DIRECTORY_RECONCILER_PREFIX); - AccountDatabaseCrawler directoryReconciliationAccountDatabaseCrawler = new AccountDatabaseCrawler( - "Reconciliation crawler", - accountsManager, - directoryReconciliationAccountDatabaseCrawlerCache, directoryReconciliationAccountDatabaseCrawlerListeners, - config.getAccountDatabaseCrawlerConfiguration().getChunkSize(), - config.getAccountDatabaseCrawlerConfiguration().getChunkIntervalMs() - ); - - AccountDatabaseCrawlerCache accountCleanerAccountDatabaseCrawlerCache = - new AccountDatabaseCrawlerCache(cacheCluster, AccountDatabaseCrawlerCache.ACCOUNT_CLEANER_PREFIX); - AccountDatabaseCrawler accountCleanerAccountDatabaseCrawler = new AccountDatabaseCrawler("Account cleaner crawler", - accountsManager, - accountCleanerAccountDatabaseCrawlerCache, List.of(new AccountCleaner(accountsManager, accountDeletionExecutor)), - config.getAccountDatabaseCrawlerConfiguration().getChunkSize(), - config.getAccountDatabaseCrawlerConfiguration().getChunkIntervalMs() - ); - - // TODO listeners must be ordered so that ones that directly update accounts come last, so that read-only ones are not working with stale data - final List accountDatabaseCrawlerListeners = List.of( - new NonNormalizedAccountCrawlerListener(accountsManager, metricsCluster), - new ContactDiscoveryWriter(accountsManager), - // PushFeedbackProcessor may update device properties - new PushFeedbackProcessor(accountsManager)); - - AccountDatabaseCrawlerCache accountDatabaseCrawlerCache = new AccountDatabaseCrawlerCache(cacheCluster, - AccountDatabaseCrawlerCache.GENERAL_PURPOSE_PREFIX); - AccountDatabaseCrawler accountDatabaseCrawler = new AccountDatabaseCrawler("General-purpose account crawler", - accountsManager, - accountDatabaseCrawlerCache, accountDatabaseCrawlerListeners, - config.getAccountDatabaseCrawlerConfiguration().getChunkSize(), - config.getAccountDatabaseCrawlerConfiguration().getChunkIntervalMs() - ); - - DeletedAccountsTableCrawler deletedAccountsTableCrawler = new DeletedAccountsTableCrawler(deletedAccountsManager, deletedAccountsDirectoryReconcilers, cacheCluster, recurringJobExecutor); - - HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build(); - FixerClient fixerClient = new FixerClient(currencyClient, config.getPaymentsServiceConfiguration().getFixerApiKey()); - CoinMarketCapClient coinMarketCapClient = new CoinMarketCapClient(currencyClient, config.getPaymentsServiceConfiguration().getCoinMarketCapApiKey(), config.getPaymentsServiceConfiguration().getCoinMarketCapCurrencyIds()); - CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, - cacheCluster, config.getPaymentsServiceConfiguration().getPaymentCurrencies(), Clock.systemUTC()); - - environment.lifecycle().manage(apnSender); - environment.lifecycle().manage(apnPushNotificationScheduler); - environment.lifecycle().manage(pubSubManager); - environment.lifecycle().manage(accountDatabaseCrawler); - environment.lifecycle().manage(directoryReconciliationAccountDatabaseCrawler); - environment.lifecycle().manage(accountCleanerAccountDatabaseCrawler); - environment.lifecycle().manage(deletedAccountsTableCrawler); - environment.lifecycle().manage(messagesCache); - environment.lifecycle().manage(messagePersister); - environment.lifecycle().manage(clientPresenceManager); - environment.lifecycle().manage(currencyManager); - environment.lifecycle().manage(directoryQueue); - environment.lifecycle().manage(registrationServiceClient); - - StaticCredentialsProvider cdnCredentialsProvider = StaticCredentialsProvider - .create(AwsBasicCredentials.create( - config.getCdnConfiguration().getAccessKey(), - config.getCdnConfiguration().getAccessSecret())); - S3Client cdnS3Client = S3Client.builder() - .credentialsProvider(cdnCredentialsProvider) - .region(Region.of(config.getCdnConfiguration().getRegion())) - .build(); - PostPolicyGenerator profileCdnPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().getRegion(), - config.getCdnConfiguration().getBucket(), config.getCdnConfiguration().getAccessKey()); - PolicySigner profileCdnPolicySigner = new PolicySigner(config.getCdnConfiguration().getAccessSecret(), - config.getCdnConfiguration().getRegion()); - - ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().getServerSecret()); - ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams); - ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams); - ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams); - - AuthFilter accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator( - accountAuthenticator).buildAuthFilter(); - AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator( - disabledPermittedAccountAuthenticator).buildAuthFilter(); - - environment.servlets() - .addFilter("RemoteDeprecationFilter", new RemoteDeprecationFilter(dynamicConfigurationManager)) - .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, "/*"); - - environment.jersey().register(new RequestStatisticsFilter(TrafficSource.HTTP)); - environment.jersey().register(MultiRecipientMessageProvider.class); - environment.jersey().register(new MetricsApplicationEventListener(TrafficSource.HTTP)); - environment.jersey() - .register(new PolymorphicAuthDynamicFeature<>(ImmutableMap.of(AuthenticatedAccount.class, accountAuthFilter, - DisabledPermittedAuthenticatedAccount.class, disabledPermittedAccountAuthFilter))); - environment.jersey().register(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))); - environment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)); - environment.jersey().register(new TimestampResponseFilter()); - - /// - WebSocketEnvironment webSocketEnvironment = new WebSocketEnvironment<>(environment, - config.getWebSocketConfiguration(), 90000); - webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator)); - webSocketEnvironment.setConnectListener( - new AuthenticatedConnectListener(receiptSender, messagesManager, pushNotificationManager, - clientPresenceManager, websocketScheduledExecutor)); - webSocketEnvironment.jersey() - .register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)); - webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET)); - webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class); - webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET)); - webSocketEnvironment.jersey().register(new KeepAliveController(clientPresenceManager)); - - // these should be common, but use @Auth DisabledPermittedAccount, which isn’t supported yet on websocket - environment.jersey().register( - new AccountController(pendingAccountsManager, accountsManager, rateLimiters, - registrationServiceClient, dynamicConfigurationManager, turnTokenGenerator, config.getTestDevices(), - captchaChecker, pushNotificationManager, changeNumberManager, registrationLockVerificationManager, - registrationRecoveryPasswordsManager, usernameHashZkProofVerifier, clock)); - - environment.jersey().register(new KeysController(rateLimiters, keys, accountsManager)); - - boolean registeredSpamFilter = false; - ReportSpamTokenProvider reportSpamTokenProvider = null; - - for (final SpamFilter filter : ServiceLoader.load(SpamFilter.class)) { - if (filter.getClass().isAnnotationPresent(FilterSpam.class)) { - try { - filter.configure(config.getSpamFilterConfiguration().getEnvironment()); - - ReportSpamTokenProvider thisProvider = filter.getReportSpamTokenProvider(); - if (reportSpamTokenProvider == null) { - reportSpamTokenProvider = thisProvider; - } else if (thisProvider != null) { - log.info("Multiple spam report token providers found. Using the first."); - } - - filter.getReportedMessageListeners().forEach(reportMessageManager::addListener); - - environment.lifecycle().manage(filter); - environment.jersey().register(filter); - webSocketEnvironment.jersey().register(filter); - - log.info("Registered spam filter: {}", filter.getClass().getName()); - registeredSpamFilter = true; - } catch (final Exception e) { - log.warn("Failed to register spam filter: {}", filter.getClass().getName(), e); - } - } else { - log.warn("Spam filter {} not annotated with @FilterSpam and will not be installed", - filter.getClass().getName()); - } - - if (filter instanceof RateLimitChallengeListener) { - log.info("Registered rate limit challenge listener: {}", filter.getClass().getName()); - rateLimitChallengeManager.addListener((RateLimitChallengeListener) filter); - } - } - - if (!registeredSpamFilter) { - log.warn("No spam filters installed"); - } - - if (reportSpamTokenProvider == null) { - log.warn("No spam-reporting token providers found; using default (no-op) provider as a default"); - reportSpamTokenProvider = ReportSpamTokenProvider.noop(); - } - - final List commonControllers = Lists.newArrayList( - new AccountControllerV2(accountsManager, changeNumberManager, phoneVerificationTokenManager, - registrationLockVerificationManager, rateLimiters), - new ArtController(rateLimiters, artCredentialsGenerator), - new AttachmentControllerV2(rateLimiters, config.getAwsAttachmentsConfiguration().getAccessKey(), config.getAwsAttachmentsConfiguration().getAccessSecret(), config.getAwsAttachmentsConfiguration().getRegion(), config.getAwsAttachmentsConfiguration().getBucket()), - new AttachmentControllerV3(rateLimiters, config.getGcpAttachmentsConfiguration().getDomain(), config.getGcpAttachmentsConfiguration().getEmail(), config.getGcpAttachmentsConfiguration().getMaxSizeInBytes(), config.getGcpAttachmentsConfiguration().getPathPrefix(), config.getGcpAttachmentsConfiguration().getRsaSigningKey()), - new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().getCertificate(), config.getDeliveryCertificate().getPrivateKey(), config.getDeliveryCertificate().getExpiresDays()), zkAuthOperations, clock), - new ChallengeController(rateLimitChallengeManager), - new DeviceController(pendingDevicesManager, accountsManager, messagesManager, keys, rateLimiters, config.getMaxDevices()), - new DirectoryController(directoryCredentialsGenerator), - new DirectoryV2Controller(directoryV2CredentialsGenerator), - new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(), - ReceiptCredentialPresentation::new), - new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor, - reportSpamTokenProvider), - new PaymentsController(currencyManager, paymentsCredentialsGenerator), - new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager, - profileBadgeConverter, config.getBadges(), cdnS3Client, profileCdnPolicyGenerator, profileCdnPolicySigner, - config.getCdnConfiguration().getBucket(), zkProfileOperations, batchIdentityCheckExecutor), - new ProvisioningController(rateLimiters, provisioningManager), - new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager, - rateLimiters), - new RemoteConfigController(remoteConfigsManager, adminEventLogger, - config.getRemoteConfigConfiguration().getAuthorizedTokens(), - config.getRemoteConfigConfiguration().getGlobalConfig()), - new SecureBackupController(backupCredentialsGenerator, accountsManager), - new SecureStorageController(storageCredentialsGenerator), - new SecureValueRecovery2Controller(svr2CredentialsGenerator), - new StickerController(rateLimiters, config.getCdnConfiguration().getAccessKey(), - config.getCdnConfiguration().getAccessSecret(), config.getCdnConfiguration().getRegion(), - config.getCdnConfiguration().getBucket()) - ); - if (config.getSubscription() != null && config.getOneTimeDonations() != null) { - commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(), - subscriptionManager, stripeManager, braintreeManager, zkReceiptOperations, issuedReceiptsManager, profileBadgeConverter, - resourceBundleLevelTranslator)); - } - - for (Object controller : commonControllers) { - environment.jersey().register(controller); - webSocketEnvironment.jersey().register(controller); - } - - WebSocketEnvironment provisioningEnvironment = new WebSocketEnvironment<>(environment, - webSocketEnvironment.getRequestLog(), 60000); - provisioningEnvironment.jersey().register(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)); - provisioningEnvironment.setConnectListener(new ProvisioningConnectListener(pubSubManager)); - provisioningEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET)); - provisioningEnvironment.jersey().register(new KeepAliveController(clientPresenceManager)); - - registerCorsFilter(environment); - registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment); - - environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE); - webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE); - provisioningEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE); - - WebSocketResourceProviderFactory webSocketServlet = new WebSocketResourceProviderFactory<>( - webSocketEnvironment, AuthenticatedAccount.class, config.getWebSocketConfiguration()); - WebSocketResourceProviderFactory provisioningServlet = new WebSocketResourceProviderFactory<>( - provisioningEnvironment, AuthenticatedAccount.class, config.getWebSocketConfiguration()); - - ServletRegistration.Dynamic websocket = environment.servlets().addServlet("WebSocket", webSocketServlet); - ServletRegistration.Dynamic provisioning = environment.servlets().addServlet("Provisioning", provisioningServlet); - - websocket.addMapping("/v1/websocket/"); - websocket.setAsyncSupported(true); - - provisioning.addMapping("/v1/websocket/provisioning/"); - provisioning.setAsyncSupported(true); - - environment.admin().addTask(new SetRequestLoggingEnabledTask()); - environment.admin().addTask(new SetCrawlerAccelerationTask(accountDatabaseCrawlerCache)); - - environment.healthChecks().register("cacheCluster", new RedisClusterHealthCheck(cacheCluster)); - - environment.lifecycle().manage(new ApplicationShutdownMonitor(Metrics.globalRegistry)); - - environment.metrics().register(name(CpuUsageGauge.class, "cpu"), new CpuUsageGauge(3, TimeUnit.SECONDS)); - environment.metrics().register(name(FreeMemoryGauge.class, "free_memory"), new FreeMemoryGauge()); - environment.metrics().register(name(NetworkSentGauge.class, "bytes_sent"), new NetworkSentGauge()); - environment.metrics().register(name(NetworkReceivedGauge.class, "bytes_received"), new NetworkReceivedGauge()); - environment.metrics().register(name(FileDescriptorGauge.class, "fd_count"), new FileDescriptorGauge()); - environment.metrics().register(name(MaxFileDescriptorGauge.class, "max_fd_count"), new MaxFileDescriptorGauge()); - environment.metrics() - .register(name(OperatingSystemMemoryGauge.class, "buffers"), new OperatingSystemMemoryGauge("Buffers")); - environment.metrics() - .register(name(OperatingSystemMemoryGauge.class, "cached"), new OperatingSystemMemoryGauge("Cached")); - - BufferPoolGauges.registerMetrics(); - GarbageCollectionGauges.registerMetrics(); - } - - private void registerExceptionMappers(Environment environment, - WebSocketEnvironment webSocketEnvironment, - WebSocketEnvironment provisioningEnvironment) { - - List.of( - new LoggingUnhandledExceptionMapper(), - new CompletionExceptionMapper(), - new IOExceptionMapper(), - new RateLimitExceededExceptionMapper(), - new InvalidWebsocketAddressExceptionMapper(), - new DeviceLimitExceededExceptionMapper(), - new ServerRejectedExceptionMapper(), - new ImpossiblePhoneNumberExceptionMapper(), - new NonNormalizedPhoneNumberExceptionMapper(), - new JsonMappingExceptionMapper() - ).forEach(exceptionMapper -> { - environment.jersey().register(exceptionMapper); - webSocketEnvironment.jersey().register(exceptionMapper); - provisioningEnvironment.jersey().register(exceptionMapper); - }); - } - - private void registerCorsFilter(Environment environment) { - FilterRegistration.Dynamic filter = environment.servlets().addFilter("CORS", CrossOriginFilter.class); - filter.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), true, "/*"); - filter.setInitParameter("allowedOrigins", "*"); - filter.setInitParameter("allowedHeaders", "Content-Type,Authorization,X-Requested-With,Content-Length,Accept,Origin,X-Signal-Agent"); - filter.setInitParameter("allowedMethods", "GET,PUT,POST,DELETE,OPTIONS"); - filter.setInitParameter("preflightMaxAge", "5184000"); - filter.setInitParameter("allowCredentials", "true"); - } - - public static void main(String[] args) throws Exception { - new WhisperServerService().run(args); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AccountAndAuthenticatedDeviceHolder.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/AccountAndAuthenticatedDeviceHolder.java deleted file mode 100644 index bf10bd657..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AccountAndAuthenticatedDeviceHolder.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; - -public interface AccountAndAuthenticatedDeviceHolder { - - Account getAccount(); - - Device getAuthenticatedDevice(); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AccountAuthenticator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/AccountAuthenticator.java deleted file mode 100644 index a1e9ffa7e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AccountAuthenticator.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.auth; - -import io.dropwizard.auth.Authenticator; -import io.dropwizard.auth.basic.BasicCredentials; -import java.util.Optional; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.storage.AccountsManager; - -public class AccountAuthenticator extends BaseAccountAuthenticator implements - Authenticator { - - public AccountAuthenticator(AccountsManager accountsManager) { - super(accountsManager); - } - - @Override - public Optional authenticate(BasicCredentials basicCredentials) { - return super.authenticate(basicCredentials, true); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/Anonymous.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/Anonymous.java deleted file mode 100644 index fa8a8190e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/Anonymous.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.util.Base64; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; - -public class Anonymous { - - private final byte[] unidentifiedSenderAccessKey; - - public Anonymous(String header) { - try { - this.unidentifiedSenderAccessKey = Base64.getDecoder().decode(header); - } catch (IllegalArgumentException e) { - throw new WebApplicationException(e, Response.Status.UNAUTHORIZED); - } - } - - public byte[] getAccessKey() { - return unidentifiedSenderAccessKey; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthEnablementRefreshRequirementProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthEnablementRefreshRequirementProvider.java deleted file mode 100644 index 1e3e5b22a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthEnablementRefreshRequirementProvider.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import com.google.common.annotations.VisibleForTesting; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Pair; - -/** - * This {@link WebsocketRefreshRequirementProvider} observes intra-request changes in {@link Account#isEnabled()} and - * {@link Device#isEnabled()}. - *

- * If a change in {@link Account#isEnabled()} or any associated {@link Device#isEnabled()} is observed, then any active - * WebSocket connections for the account must be closed in order for clients to get a refreshed - * {@link io.dropwizard.auth.Auth} object with a current device list. - * - * @see AuthenticatedAccount - * @see DisabledPermittedAuthenticatedAccount - */ -public class AuthEnablementRefreshRequirementProvider implements WebsocketRefreshRequirementProvider { - - private final AccountsManager accountsManager; - - private static final Logger logger = LoggerFactory.getLogger(AuthEnablementRefreshRequirementProvider.class); - - private static final String ACCOUNT_UUID = AuthEnablementRefreshRequirementProvider.class.getName() + ".accountUuid"; - private static final String DEVICES_ENABLED = AuthEnablementRefreshRequirementProvider.class.getName() + ".devicesEnabled"; - - public AuthEnablementRefreshRequirementProvider(final AccountsManager accountsManager) { - this.accountsManager = accountsManager; - } - - @VisibleForTesting - static Map buildDevicesEnabledMap(final Account account) { - return account.getDevices().stream().collect(Collectors.toMap(Device::getId, Device::isEnabled)); - } - - @Override - public void handleRequestFiltered(final RequestEvent requestEvent) { - if (requestEvent.getUriInfo().getMatchedResourceMethod().getInvocable().getHandlingMethod().getAnnotation(ChangesDeviceEnabledState.class) != null) { - // The authenticated principal, if any, will be available after filters have run. - // Now that the account is known, capture a snapshot of `isEnabled` for the account's devices before carrying out - // the request’s business logic. - ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest()).ifPresent(account -> - setAccount(requestEvent.getContainerRequest(), account)); - } - } - - public static void setAccount(final ContainerRequest containerRequest, final Account account) { - containerRequest.setProperty(ACCOUNT_UUID, account.getUuid()); - containerRequest.setProperty(DEVICES_ENABLED, buildDevicesEnabledMap(account)); - } - - @Override - public List> handleRequestFinished(final RequestEvent requestEvent) { - // Now that the request is finished, check whether `isEnabled` changed for any of the devices. If the value did - // change or if a devices was added or removed, all devices must disconnect and reauthenticate. - if (requestEvent.getContainerRequest().getProperty(DEVICES_ENABLED) != null) { - - @SuppressWarnings("unchecked") final Map initialDevicesEnabled = - (Map) requestEvent.getContainerRequest().getProperty(DEVICES_ENABLED); - - return accountsManager.getByAccountIdentifier((UUID) requestEvent.getContainerRequest().getProperty(ACCOUNT_UUID)).map(account -> { - final Set deviceIdsToDisplace; - final Map currentDevicesEnabled = buildDevicesEnabledMap(account); - - if (!initialDevicesEnabled.equals(currentDevicesEnabled)) { - deviceIdsToDisplace = new HashSet<>(initialDevicesEnabled.keySet()); - deviceIdsToDisplace.addAll(currentDevicesEnabled.keySet()); - } else { - deviceIdsToDisplace = Collections.emptySet(); - } - - return deviceIdsToDisplace.stream() - .map(deviceId -> new Pair<>(account.getUuid(), deviceId)) - .collect(Collectors.toList()); - }).orElseGet(() -> { - logger.error("Request had account, but it is no longer present"); - return Collections.emptyList(); - }); - } else - return Collections.emptyList(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthenticatedAccount.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthenticatedAccount.java deleted file mode 100644 index 13c9e504c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthenticatedAccount.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.security.Principal; -import java.util.function.Supplier; -import javax.security.auth.Subject; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Pair; - -public class AuthenticatedAccount implements Principal, AccountAndAuthenticatedDeviceHolder { - - private final Supplier> accountAndDevice; - - public AuthenticatedAccount(final Supplier> accountAndDevice) { - this.accountAndDevice = accountAndDevice; - } - - @Override - public Account getAccount() { - return accountAndDevice.get().first(); - } - - @Override - public Device getAuthenticatedDevice() { - return accountAndDevice.get().second(); - } - - // Principal implementation - - @Override - public String getName() { - return null; - } - - @Override - public boolean implies(final Subject subject) { - return false; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/BaseAccountAuthenticator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/BaseAccountAuthenticator.java deleted file mode 100644 index 9ca61e2a7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/BaseAccountAuthenticator.java +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.auth.basic.BasicCredentials; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import java.time.Clock; -import java.time.Duration; -import java.time.temporal.ChronoUnit; -import java.util.Optional; -import java.util.UUID; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.RefreshingAccountAndDeviceSupplier; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.Util; - -public class BaseAccountAuthenticator { - - private static final String AUTHENTICATION_COUNTER_NAME = name(BaseAccountAuthenticator.class, "authentication"); - private static final String ENABLED_NOT_REQUIRED_AUTHENTICATION_COUNTER_NAME = name(BaseAccountAuthenticator.class, - "enabledNotRequiredAuthentication"); - private static final String AUTHENTICATION_SUCCEEDED_TAG_NAME = "succeeded"; - private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = "reason"; - private static final String ENABLED_TAG_NAME = "enabled"; - private static final String AUTHENTICATION_HAS_STORY_CAPABILITY = "hasStoryCapability"; - - private static final String STORY_ADOPTION_COUNTER_NAME = name(BaseAccountAuthenticator.class, "storyAdoption"); - - private static final String DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(BaseAccountAuthenticator.class, "daysSinceLastSeen"); - private static final String IS_PRIMARY_DEVICE_TAG = "isPrimary"; - - @VisibleForTesting - static final char DEVICE_ID_SEPARATOR = '.'; - - private final AccountsManager accountsManager; - private final Clock clock; - - public BaseAccountAuthenticator(AccountsManager accountsManager) { - this(accountsManager, Clock.systemUTC()); - } - - @VisibleForTesting - public BaseAccountAuthenticator(AccountsManager accountsManager, Clock clock) { - this.accountsManager = accountsManager; - this.clock = clock; - } - - static Pair getIdentifierAndDeviceId(final String basicUsername) { - final String identifier; - final long deviceId; - - final int deviceIdSeparatorIndex = basicUsername.indexOf(DEVICE_ID_SEPARATOR); - - if (deviceIdSeparatorIndex == -1) { - identifier = basicUsername; - deviceId = Device.MASTER_ID; - } else { - identifier = basicUsername.substring(0, deviceIdSeparatorIndex); - deviceId = Long.parseLong(basicUsername.substring(deviceIdSeparatorIndex + 1)); - } - - return new Pair<>(identifier, deviceId); - } - - public Optional authenticate(BasicCredentials basicCredentials, boolean enabledRequired) { - boolean succeeded = false; - String failureReason = null; - boolean hasStoryCapability = false; - - try { - final UUID accountUuid; - final long deviceId; - { - final Pair identifierAndDeviceId = getIdentifierAndDeviceId(basicCredentials.getUsername()); - - accountUuid = UUID.fromString(identifierAndDeviceId.first()); - deviceId = identifierAndDeviceId.second(); - } - - Optional account = accountsManager.getByAccountIdentifier(accountUuid); - - if (account.isEmpty()) { - failureReason = "noSuchAccount"; - return Optional.empty(); - } - - hasStoryCapability = account.map(Account::isStoriesSupported).orElse(false); - - Optional device = account.get().getDevice(deviceId); - - if (device.isEmpty()) { - failureReason = "noSuchDevice"; - return Optional.empty(); - } - - if (enabledRequired) { - final boolean deviceDisabled = !device.get().isEnabled(); - if (deviceDisabled) { - failureReason = "deviceDisabled"; - } - - final boolean accountDisabled = !account.get().isEnabled(); - if (accountDisabled) { - failureReason = "accountDisabled"; - } - if (accountDisabled || deviceDisabled) { - return Optional.empty(); - } - } else { - Metrics.counter(ENABLED_NOT_REQUIRED_AUTHENTICATION_COUNTER_NAME, - ENABLED_TAG_NAME, String.valueOf(device.get().isEnabled() && account.get().isEnabled()), - IS_PRIMARY_DEVICE_TAG, String.valueOf(device.get().isMaster())) - .increment(); - } - - SaltedTokenHash deviceSaltedTokenHash = device.get().getAuthTokenHash(); - if (deviceSaltedTokenHash.verify(basicCredentials.getPassword())) { - succeeded = true; - Account authenticatedAccount = updateLastSeen(account.get(), device.get()); - if (deviceSaltedTokenHash.getVersion() != SaltedTokenHash.CURRENT_VERSION) { - authenticatedAccount = accountsManager.updateDeviceAuthentication( - authenticatedAccount, - device.get(), - SaltedTokenHash.generateFor(basicCredentials.getPassword())); // new credentials have current version - } - return Optional.of(new AuthenticatedAccount( - new RefreshingAccountAndDeviceSupplier(authenticatedAccount, device.get().getId(), accountsManager))); - } - - return Optional.empty(); - } catch (IllegalArgumentException | InvalidAuthorizationHeaderException iae) { - failureReason = "invalidHeader"; - return Optional.empty(); - } finally { - Tags tags = Tags.of( - AUTHENTICATION_SUCCEEDED_TAG_NAME, String.valueOf(succeeded)); - - if (StringUtils.isNotBlank(failureReason)) { - tags = tags.and(AUTHENTICATION_FAILURE_REASON_TAG_NAME, failureReason); - } - - Metrics.counter(AUTHENTICATION_COUNTER_NAME, tags).increment(); - - Tags storyTags = Tags.of(AUTHENTICATION_HAS_STORY_CAPABILITY, String.valueOf(hasStoryCapability)); - Metrics.counter(STORY_ADOPTION_COUNTER_NAME, storyTags).increment(); - } - } - - @VisibleForTesting - public Account updateLastSeen(Account account, Device device) { - // compute a non-negative integer between 0 and 86400. - long n = Util.ensureNonNegativeLong(account.getUuid().getLeastSignificantBits()); - final long lastSeenOffsetSeconds = n % ChronoUnit.DAYS.getDuration().toSeconds(); - - // produce a truncated timestamp which is either today at UTC midnight - // or yesterday at UTC midnight, based on per-user randomized offset used. - final long todayInMillisWithOffset = Util.todayInMillisGivenOffsetFromNow(clock, Duration.ofSeconds(lastSeenOffsetSeconds).negated()); - - // only update the device's last seen time when it falls behind the truncated timestamp. - // this ensure a few things: - // (1) each account will only update last-seen at most once per day - // (2) these updates will occur throughout the day rather than all occurring at UTC midnight. - if (device.getLastSeen() < todayInMillisWithOffset) { - Metrics.summary(DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME, IS_PRIMARY_DEVICE_TAG, String.valueOf(device.isMaster())) - .record(Duration.ofMillis(todayInMillisWithOffset - device.getLastSeen()).toDays()); - - return accountsManager.updateDeviceLastSeen(account, device, Util.todayInMillis(clock)); - } - - return account; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeader.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeader.java deleted file mode 100644 index 05e4f4f27..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeader.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.auth; - -import java.util.Base64; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.util.Pair; - -public class BasicAuthorizationHeader { - - private final String username; - private final long deviceId; - private final String password; - - private BasicAuthorizationHeader(final String username, final long deviceId, final String password) { - this.username = username; - this.deviceId = deviceId; - this.password = password; - } - - public static BasicAuthorizationHeader fromString(final String header) throws InvalidAuthorizationHeaderException { - try { - if (StringUtils.isBlank(header)) { - throw new InvalidAuthorizationHeaderException("Blank header"); - } - - final int spaceIndex = header.indexOf(' '); - - if (spaceIndex == -1) { - throw new InvalidAuthorizationHeaderException("Invalid authorization header: " + header); - } - - final String authorizationType = header.substring(0, spaceIndex); - - if (!"Basic".equals(authorizationType)) { - throw new InvalidAuthorizationHeaderException("Unsupported authorization method: " + authorizationType); - } - - final String credentials; - - try { - credentials = new String(Base64.getDecoder().decode(header.substring(spaceIndex + 1))); - } catch (final IndexOutOfBoundsException e) { - throw new InvalidAuthorizationHeaderException("Missing credentials"); - } - - if (StringUtils.isEmpty(credentials)) { - throw new InvalidAuthorizationHeaderException("Bad decoded value: " + credentials); - } - - final int credentialSeparatorIndex = credentials.indexOf(':'); - - if (credentialSeparatorIndex == -1) { - throw new InvalidAuthorizationHeaderException("Badly-formatted credentials: " + credentials); - } - - final String usernameComponent = credentials.substring(0, credentialSeparatorIndex); - - final String username; - final long deviceId; - { - final Pair identifierAndDeviceId = - BaseAccountAuthenticator.getIdentifierAndDeviceId(usernameComponent); - - username = identifierAndDeviceId.first(); - deviceId = identifierAndDeviceId.second(); - } - - final String password = credentials.substring(credentialSeparatorIndex + 1); - - if (StringUtils.isAnyBlank(username, password)) { - throw new InvalidAuthorizationHeaderException("Username or password were blank"); - } - - return new BasicAuthorizationHeader(username, deviceId, password); - } catch (final IllegalArgumentException | IndexOutOfBoundsException e) { - throw new InvalidAuthorizationHeaderException(e); - } - } - - public String getUsername() { - return username; - } - - public long getDeviceId() { - return deviceId; - } - - public String getPassword() { - return password; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/CertificateGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/CertificateGenerator.java deleted file mode 100644 index 86a927e6e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/CertificateGenerator.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; -import java.security.InvalidKeyException; -import java.util.Base64; -import java.util.concurrent.TimeUnit; -import org.signal.libsignal.protocol.ecc.Curve; -import org.signal.libsignal.protocol.ecc.ECPrivateKey; -import org.whispersystems.textsecuregcm.entities.MessageProtos.SenderCertificate; -import org.whispersystems.textsecuregcm.entities.MessageProtos.ServerCertificate; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; - -public class CertificateGenerator { - - private final ECPrivateKey privateKey; - private final int expiresDays; - private final ServerCertificate serverCertificate; - - public CertificateGenerator(byte[] serverCertificate, ECPrivateKey privateKey, int expiresDays) - throws InvalidProtocolBufferException - { - this.privateKey = privateKey; - this.expiresDays = expiresDays; - this.serverCertificate = ServerCertificate.parseFrom(serverCertificate); - } - - public byte[] createFor(Account account, Device device, boolean includeE164) throws InvalidKeyException { - SenderCertificate.Certificate.Builder builder = SenderCertificate.Certificate.newBuilder() - .setSenderDevice(Math.toIntExact(device.getId())) - .setExpires(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(expiresDays)) - .setIdentityKey(ByteString.copyFrom(Base64.getDecoder().decode(account.getIdentityKey()))) - .setSigner(serverCertificate) - .setSenderUuid(account.getUuid().toString()); - - if (includeE164) { - builder.setSender(account.getNumber()); - } - - byte[] certificate = builder.build().toByteArray(); - byte[] signature; - try { - signature = Curve.calculateSignature(privateKey, certificate); - } catch (org.signal.libsignal.protocol.InvalidKeyException e) { - throw new InvalidKeyException(e); - } - - return SenderCertificate.newBuilder() - .setCertificate(ByteString.copyFrom(certificate)) - .setSignature(ByteString.copyFrom(signature)) - .build() - .toByteArray(); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ChangesDeviceEnabledState.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ChangesDeviceEnabledState.java deleted file mode 100644 index dc4911cdb..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ChangesDeviceEnabledState.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Indicates that an endpoint may change the "enabled" state of one or more devices associated with an account, and that - * any websockets associated with the account may need to be refreshed after a call to that endpoint. - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ChangesDeviceEnabledState { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/CombinedUnidentifiedSenderAccessKeys.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/CombinedUnidentifiedSenderAccessKeys.java deleted file mode 100644 index f101692bf..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/CombinedUnidentifiedSenderAccessKeys.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.util.Base64; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; - -public class CombinedUnidentifiedSenderAccessKeys { - private final byte[] combinedUnidentifiedSenderAccessKeys; - - public CombinedUnidentifiedSenderAccessKeys(String header) { - try { - this.combinedUnidentifiedSenderAccessKeys = Base64.getDecoder().decode(header); - if (this.combinedUnidentifiedSenderAccessKeys == null || this.combinedUnidentifiedSenderAccessKeys.length != 16) { - throw new WebApplicationException("Invalid combined unidentified sender access keys", Status.UNAUTHORIZED); - } - } catch (IllegalArgumentException e) { - throw new WebApplicationException(e, Response.Status.UNAUTHORIZED); - } - } - - public byte[] getAccessKeys() { - return combinedUnidentifiedSenderAccessKeys; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ContainerRequestUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ContainerRequestUtil.java deleted file mode 100644 index f551b9761..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ContainerRequestUtil.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import org.glassfish.jersey.server.ContainerRequest; -import org.whispersystems.textsecuregcm.storage.Account; -import javax.ws.rs.core.SecurityContext; -import java.util.Optional; - -class ContainerRequestUtil { - - static Optional getAuthenticatedAccount(final ContainerRequest request) { - return Optional.ofNullable(request.getSecurityContext()) - .map(SecurityContext::getUserPrincipal) - .map(principal -> principal instanceof AccountAndAuthenticatedDeviceHolder - ? ((AccountAndAuthenticatedDeviceHolder) principal).getAccount() : null); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/DisabledPermittedAccountAuthenticator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/DisabledPermittedAccountAuthenticator.java deleted file mode 100644 index 03a365ad8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/DisabledPermittedAccountAuthenticator.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import io.dropwizard.auth.Authenticator; -import io.dropwizard.auth.basic.BasicCredentials; -import java.util.Optional; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.storage.AccountsManager; - -public class DisabledPermittedAccountAuthenticator extends BaseAccountAuthenticator implements - Authenticator { - - public DisabledPermittedAccountAuthenticator(AccountsManager accountsManager) { - super(accountsManager); - } - - @Override - public Optional authenticate(BasicCredentials credentials) { - Optional account = super.authenticate(credentials, false); - return account.map(DisabledPermittedAuthenticatedAccount::new); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/DisabledPermittedAuthenticatedAccount.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/DisabledPermittedAuthenticatedAccount.java deleted file mode 100644 index 2b4fd73f1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/DisabledPermittedAuthenticatedAccount.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.security.Principal; -import javax.security.auth.Subject; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; - -public class DisabledPermittedAuthenticatedAccount implements Principal, AccountAndAuthenticatedDeviceHolder { - - private final AuthenticatedAccount authenticatedAccount; - - public DisabledPermittedAuthenticatedAccount(final AuthenticatedAccount authenticatedAccount) { - this.authenticatedAccount = authenticatedAccount; - } - - @Override - public Account getAccount() { - return authenticatedAccount.getAccount(); - } - - @Override - public Device getAuthenticatedDevice() { - return authenticatedAccount.getAuthenticatedDevice(); - } - - // Principal implementation - - @Override - public String getName() { - return null; - } - - @Override - public boolean implies(Subject subject) { - return false; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentials.java deleted file mode 100644 index 5364d460e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentials.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - - -public record ExternalServiceCredentials(String username, String password) { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java deleted file mode 100644 index 11d894b75..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static java.util.Objects.requireNonNull; -import static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256ToHexString; -import static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256TruncatedToHexString; -import static org.whispersystems.textsecuregcm.util.HmacUtils.hmacHexStringsEqual; - -import java.time.Clock; -import java.util.Optional; -import java.util.UUID; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.Validate; - -public class ExternalServiceCredentialsGenerator { - - private static final int TRUNCATE_LENGTH = 10; - - private static final String DELIMITER = ":"; - - private final byte[] key; - - private final byte[] userDerivationKey; - - private final boolean prependUsername; - - private final boolean truncateSignature; - - private final Clock clock; - - - public static ExternalServiceCredentialsGenerator.Builder builder(final byte[] key) { - return new Builder(key); - } - - private ExternalServiceCredentialsGenerator( - final byte[] key, - final byte[] userDerivationKey, - final boolean prependUsername, - final boolean truncateSignature, - final Clock clock) { - this.key = requireNonNull(key); - this.userDerivationKey = requireNonNull(userDerivationKey); - this.prependUsername = prependUsername; - this.truncateSignature = truncateSignature; - this.clock = requireNonNull(clock); - } - - /** - * A convenience method for the case of identity in the form of {@link UUID}. - * @param uuid identity to generate credentials for - * @return an instance of {@link ExternalServiceCredentials} - */ - public ExternalServiceCredentials generateForUuid(final UUID uuid) { - return generateFor(uuid.toString()); - } - - /** - * Generates `ExternalServiceCredentials` for the given identity following this generator's configuration. - * @param identity identity string to generate credentials for - * @return an instance of {@link ExternalServiceCredentials} - */ - public ExternalServiceCredentials generateFor(final String identity) { - final String username = shouldDeriveUsername() - ? hmac256TruncatedToHexString(userDerivationKey, identity, TRUNCATE_LENGTH) - : identity; - - final long currentTimeSeconds = currentTimeSeconds(); - - final String dataToSign = username + DELIMITER + currentTimeSeconds; - - final String signature = truncateSignature - ? hmac256TruncatedToHexString(key, dataToSign, TRUNCATE_LENGTH) - : hmac256ToHexString(key, dataToSign); - - final String token = (prependUsername ? dataToSign : currentTimeSeconds) + DELIMITER + signature; - - return new ExternalServiceCredentials(username, token); - } - - /** - * In certain cases, identity (as it was passed to `generateFor` method) - * is a part of the signature (`password`, in terms of `ExternalServiceCredentials`) string itself. - * For such cases, this method returns the value of the identity string. - * @param password `password` part of `ExternalServiceCredentials` - * @return non-empty optional with an identity string value, or empty if value can't be extracted. - */ - public Optional identityFromSignature(final String password) { - // for some generators, identity in the clear is just not a part of the password - if (!prependUsername || shouldDeriveUsername() || StringUtils.isBlank(password)) { - return Optional.empty(); - } - // checking for the case of unexpected format - return StringUtils.countMatches(password, DELIMITER) == 2 - ? Optional.of(password.substring(0, password.indexOf(DELIMITER))) - : Optional.empty(); - } - - /** - * Given an instance of {@link ExternalServiceCredentials} object, checks that the password - * matches the username taking into accound this generator's configuration. - * @param credentials an instance of {@link ExternalServiceCredentials} - * @return An optional with a timestamp (seconds) of when the credentials were generated, - * or an empty optional if the password doesn't match the username for any reason (including malformed data) - */ - public Optional validateAndGetTimestamp(final ExternalServiceCredentials credentials) { - final String[] parts = requireNonNull(credentials).password().split(DELIMITER); - final String timestampSeconds; - final String actualSignature; - - // making sure password format matches our expectations based on the generator configuration - if (parts.length == 3 && prependUsername) { - final String username = parts[0]; - // username has to match the one from `credentials` - if (!credentials.username().equals(username)) { - return Optional.empty(); - } - timestampSeconds = parts[1]; - actualSignature = parts[2]; - } else if (parts.length == 2 && !prependUsername) { - timestampSeconds = parts[0]; - actualSignature = parts[1]; - } else { - // unexpected password format - return Optional.empty(); - } - - final String signedData = credentials.username() + DELIMITER + timestampSeconds; - final String expectedSignature = truncateSignature - ? hmac256TruncatedToHexString(key, signedData, TRUNCATE_LENGTH) - : hmac256ToHexString(key, signedData); - - // if the signature is valid it's safe to parse the `timestampSeconds` string into Long - return hmacHexStringsEqual(expectedSignature, actualSignature) - ? Optional.of(Long.valueOf(timestampSeconds)) - : Optional.empty(); - } - - /** - * Given an instance of {@link ExternalServiceCredentials} object and the max allowed age for those credentials, - * checks if credentials are valid and not expired. - * @param credentials an instance of {@link ExternalServiceCredentials} - * @param maxAgeSeconds age in seconds - * @return An optional with a timestamp (seconds) of when the credentials were generated, - * or an empty optional if the password doesn't match the username for any reason (including malformed data) - */ - public Optional validateAndGetTimestamp(final ExternalServiceCredentials credentials, final long maxAgeSeconds) { - return validateAndGetTimestamp(credentials) - .filter(ts -> currentTimeSeconds() - ts <= maxAgeSeconds); - } - - private boolean shouldDeriveUsername() { - return userDerivationKey.length > 0; - } - - private long currentTimeSeconds() { - return clock.instant().getEpochSecond(); - } - - public static class Builder { - - private final byte[] key; - - private byte[] userDerivationKey = new byte[0]; - - private boolean prependUsername = true; - - private boolean truncateSignature = true; - - private Clock clock = Clock.systemUTC(); - - - private Builder(final byte[] key) { - this.key = requireNonNull(key); - } - - public Builder withUserDerivationKey(final byte[] userDerivationKey) { - Validate.isTrue(requireNonNull(userDerivationKey).length > 0, "userDerivationKey must not be empty"); - this.userDerivationKey = userDerivationKey; - return this; - } - - public Builder withClock(final Clock clock) { - this.clock = requireNonNull(clock); - return this; - } - - public Builder prependUsername(final boolean prependUsername) { - this.prependUsername = prependUsername; - return this; - } - - public Builder truncateSignature(final boolean truncateSignature) { - this.truncateSignature = truncateSignature; - return this; - } - - public ExternalServiceCredentialsGenerator build() { - return new ExternalServiceCredentialsGenerator( - key, userDerivationKey, prependUsername, truncateSignature, clock); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/InvalidAuthorizationHeaderException.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/InvalidAuthorizationHeaderException.java deleted file mode 100644 index d5b85daa9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/InvalidAuthorizationHeaderException.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.auth; - - -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response.Status; - -public class InvalidAuthorizationHeaderException extends WebApplicationException { - public InvalidAuthorizationHeaderException(String s) { - super(s, Status.UNAUTHORIZED); - } - - public InvalidAuthorizationHeaderException(Exception e) { - super(e, Status.UNAUTHORIZED); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java deleted file mode 100644 index d6b6346f0..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; - -import javax.ws.rs.NotAuthorizedException; -import javax.ws.rs.NotFoundException; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; -import java.security.MessageDigest; -import java.util.Optional; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class OptionalAccess { - - public static final String UNIDENTIFIED = "Unidentified-Access-Key"; - - public static void verify(Optional requestAccount, - Optional accessKey, - Optional targetAccount, - String deviceSelector) - { - try { - verify(requestAccount, accessKey, targetAccount); - - if (!deviceSelector.equals("*")) { - long deviceId = Long.parseLong(deviceSelector); - - Optional targetDevice = targetAccount.get().getDevice(deviceId); - - if (targetDevice.isPresent() && targetDevice.get().isEnabled()) { - return; - } - - if (requestAccount.isPresent()) { - throw new NotFoundException(); - } else { - throw new NotAuthorizedException(Response.Status.UNAUTHORIZED); - } - } - } catch (NumberFormatException e) { - throw new WebApplicationException(Response.status(422).build()); - } - } - - public static void verify(Optional requestAccount, - Optional accessKey, - Optional targetAccount) - { - if (requestAccount.isPresent() && targetAccount.isPresent() && targetAccount.get().isEnabled()) { - return; - } - - //noinspection ConstantConditions - if (requestAccount.isPresent() && (targetAccount.isEmpty() || (targetAccount.isPresent() && !targetAccount.get().isEnabled()))) { - throw new NotFoundException(); - } - - if (accessKey.isPresent() && targetAccount.isPresent() && targetAccount.get().isEnabled() && targetAccount.get().isUnrestrictedUnidentifiedAccess()) { - return; - } - - if (accessKey.isPresent() && - targetAccount.isPresent() && - targetAccount.get().getUnidentifiedAccessKey().isPresent() && - targetAccount.get().isEnabled() && - MessageDigest.isEqual(accessKey.get().getAccessKey(), targetAccount.get().getUnidentifiedAccessKey().get())) - { - return; - } - - throw new NotAuthorizedException(Response.Status.UNAUTHORIZED); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneNumberChangeRefreshRequirementProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneNumberChangeRefreshRequirementProvider.java deleted file mode 100644 index 4ba8db0a5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneNumberChangeRefreshRequirementProvider.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Collectors; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.util.Pair; - -public class PhoneNumberChangeRefreshRequirementProvider implements WebsocketRefreshRequirementProvider { - - private static final String INITIAL_NUMBER_KEY = - PhoneNumberChangeRefreshRequirementProvider.class.getName() + ".initialNumber"; - - @Override - public void handleRequestFiltered(final RequestEvent requestEvent) { - ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest()) - .ifPresent(account -> requestEvent.getContainerRequest().setProperty(INITIAL_NUMBER_KEY, account.getNumber())); - } - - @Override - public List> handleRequestFinished(final RequestEvent requestEvent) { - final String initialNumber = (String) requestEvent.getContainerRequest().getProperty(INITIAL_NUMBER_KEY); - - if (initialNumber != null) { - final Optional maybeAuthenticatedAccount = - ContainerRequestUtil.getAuthenticatedAccount(requestEvent.getContainerRequest()); - - return maybeAuthenticatedAccount - .filter(account -> !initialNumber.equals(account.getNumber())) - .map(account -> account.getDevices().stream() - .map(device -> new Pair<>(account.getUuid(), device.getId())) - .collect(Collectors.toList())) - .orElse(Collections.emptyList()); - } else { - return Collections.emptyList(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java deleted file mode 100644 index 0c3d3b04a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.security.MessageDigest; -import java.time.Duration; -import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.ForbiddenException; -import javax.ws.rs.NotAuthorizedException; -import javax.ws.rs.ServerErrorException; -import javax.ws.rs.core.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest; -import org.whispersystems.textsecuregcm.entities.RegistrationSession; -import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; - -public class PhoneVerificationTokenManager { - - private static final Logger logger = LoggerFactory.getLogger(PhoneVerificationTokenManager.class); - private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15); - private static final long VERIFICATION_TIMEOUT_SECONDS = REGISTRATION_RPC_TIMEOUT.plusSeconds(1).getSeconds(); - - private final RegistrationServiceClient registrationServiceClient; - private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; - - public PhoneVerificationTokenManager(final RegistrationServiceClient registrationServiceClient, - final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) { - this.registrationServiceClient = registrationServiceClient; - this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager; - } - - /** - * Checks if a {@link PhoneVerificationRequest} has a token that verifies the caller has confirmed access to the e164 - * number - * - * @param number the e164 presented for verification - * @param request the request with exactly one verification token (RegistrationService sessionId or registration - * recovery password) - * @return if verification was successful, returns the verification type - * @throws BadRequestException if the number does not match the sessionId’s number - * @throws NotAuthorizedException if the session is not verified - * @throws ForbiddenException if the recovery password is not valid - * @throws InterruptedException if verification did not complete before a timeout - */ - public PhoneVerificationRequest.VerificationType verify(final String number, final PhoneVerificationRequest request) - throws InterruptedException { - - final PhoneVerificationRequest.VerificationType verificationType = request.verificationType(); - switch (verificationType) { - case SESSION -> verifyBySessionId(number, request.decodeSessionId()); - case RECOVERY_PASSWORD -> verifyByRecoveryPassword(number, request.recoveryPassword()); - } - - return verificationType; - } - - private void verifyBySessionId(final String number, final byte[] sessionId) throws InterruptedException { - try { - final RegistrationSession session = registrationServiceClient - .getSession(sessionId, REGISTRATION_RPC_TIMEOUT) - .get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .orElseThrow(() -> new NotAuthorizedException("session not verified")); - - if (!MessageDigest.isEqual(number.getBytes(), session.number().getBytes())) { - throw new BadRequestException("number does not match session"); - } - if (!session.verified()) { - throw new NotAuthorizedException("session not verified"); - } - } catch (final CancellationException | ExecutionException | TimeoutException e) { - logger.error("Registration service failure", e); - throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE); - } - } - - private void verifyByRecoveryPassword(final String number, final byte[] recoveryPassword) - throws InterruptedException { - try { - final boolean verified = registrationRecoveryPasswordsManager.verify(number, recoveryPassword) - .get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (!verified) { - throw new ForbiddenException("recoveryPassword couldn't be verified"); - } - } catch (final ExecutionException | TimeoutException e) { - throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManager.java deleted file mode 100644 index d2446fd85..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManager.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.common.annotations.VisibleForTesting; -import javax.annotation.Nullable; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.util.Util; - -public class RegistrationLockVerificationManager { - - @VisibleForTesting - public static final int FAILURE_HTTP_STATUS = 423; - - private static final String LOCKED_ACCOUNT_COUNTER_NAME = - name(RegistrationLockVerificationManager.class, "lockedAccount"); - private static final String LOCK_REASON_TAG_NAME = "lockReason"; - private static final String ALREADY_LOCKED_TAG_NAME = "alreadyLocked"; - - - private final AccountsManager accounts; - private final ClientPresenceManager clientPresenceManager; - private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator; - private final RateLimiters rateLimiters; - - public RegistrationLockVerificationManager( - final AccountsManager accounts, final ClientPresenceManager clientPresenceManager, - final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator, final RateLimiters rateLimiters) { - this.accounts = accounts; - this.clientPresenceManager = clientPresenceManager; - this.backupServiceCredentialGenerator = backupServiceCredentialGenerator; - this.rateLimiters = rateLimiters; - } - - /** - * Verifies the given registration lock credentials against the account’s current registration lock, if any - * - * @param account - * @param clientRegistrationLock - * @throws RateLimitExceededException - * @throws WebApplicationException - */ - public void verifyRegistrationLock(final Account account, @Nullable final String clientRegistrationLock) - throws RateLimitExceededException, WebApplicationException { - - final StoredRegistrationLock existingRegistrationLock = account.getRegistrationLock(); - final ExternalServiceCredentials existingBackupCredentials = - backupServiceCredentialGenerator.generateForUuid(account.getUuid()); - - if (!existingRegistrationLock.requiresClientRegistrationLock()) { - return; - } - - if (!Util.isEmpty(clientRegistrationLock)) { - rateLimiters.getPinLimiter().validate(account.getNumber()); - } - - final String phoneNumber = account.getNumber(); - - if (!existingRegistrationLock.verify(clientRegistrationLock)) { - // At this point, the client verified ownership of the phone number but doesn’t have the reglock PIN. - // Freezing the existing account credentials will definitively start the reglock timeout. - // Until the timeout, the current reglock can still be supplied, - // along with phone number verification, to restore access. - /* - boolean alreadyLocked = existingAccount.hasLockedCredentials(); - Metrics.counter(LOCKED_ACCOUNT_COUNTER_NAME, - LOCK_REASON_TAG_NAME, "verifiedNumberFailedReglock", - ALREADY_LOCKED_TAG_NAME, Boolean.toString(alreadyLocked)) - .increment(); - - final Account updatedAccount; - if (!alreadyLocked) { - updatedAccount = accounts.update(existingAccount, Account::lockAuthenticationCredentials); - } else { - updatedAccount = existingAccount; - } - - List deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList(); - clientPresenceManager.disconnectAllPresences(updatedAccount.getUuid(), deviceIds); - */ - - throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS) - .entity(new RegistrationLockFailure(existingRegistrationLock.getTimeRemaining(), - existingRegistrationLock.needsFailureCredentials() ? existingBackupCredentials : null)) - .build()); - } - - rateLimiters.getPinLimiter().clear(phoneNumber); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/SaltedTokenHash.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/SaltedTokenHash.java deleted file mode 100644 index 8cfecfe7f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/SaltedTokenHash.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.auth; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.HexFormat; -import org.signal.libsignal.protocol.kdf.HKDF; - -public record SaltedTokenHash(String hash, String salt) { - - public enum Version { - V1, - V2, - } - - public static final Version CURRENT_VERSION = Version.V2; - - private static final String V2_PREFIX = "2."; - - private static final byte[] AUTH_TOKEN_HKDF_INFO = "authtoken".getBytes(StandardCharsets.UTF_8); - - private static final int SALT_SIZE = 16; - - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - - public static SaltedTokenHash generateFor(final String token) { - final String salt = generateSalt(); - final String hash = calculateV2Hash(salt, token); - return new SaltedTokenHash(hash, salt); - } - - public Version getVersion() { - return hash.startsWith(V2_PREFIX) ? Version.V2 : Version.V1; - } - - public boolean verify(final String token) { - final String theirValue = switch (getVersion()) { - case V1 -> calculateV1Hash(salt, token); - case V2 -> calculateV2Hash(salt, token); - }; - return MessageDigest.isEqual( - theirValue.getBytes(StandardCharsets.UTF_8), - hash.getBytes(StandardCharsets.UTF_8)); - } - - private static String generateSalt() { - final byte[] salt = new byte[SALT_SIZE]; - SECURE_RANDOM.nextBytes(salt); - return HexFormat.of().formatHex(salt); - } - - private static String calculateV1Hash(final String salt, final String token) { - try { - return HexFormat.of() - .formatHex(MessageDigest.getInstance("SHA1").digest((salt + token).getBytes(StandardCharsets.UTF_8))); - } catch (final NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - } - - private static String calculateV2Hash(final String salt, final String token) { - final byte[] secret = HKDF.deriveSecrets( - token.getBytes(StandardCharsets.UTF_8), // key - salt.getBytes(StandardCharsets.UTF_8), // salt - AUTH_TOKEN_HKDF_INFO, - 32); - return V2_PREFIX + HexFormat.of().formatHex(secret); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredRegistrationLock.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredRegistrationLock.java deleted file mode 100644 index 96f8f7057..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredRegistrationLock.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import com.google.common.annotations.VisibleForTesting; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import javax.annotation.Nullable; -import org.whispersystems.textsecuregcm.util.Util; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class StoredRegistrationLock { - - private final Optional registrationLock; - - private final Optional registrationLockSalt; - - private final long lastSeen; - - /** - * @return milliseconds since the last time the account was seen. - */ - private long timeSinceLastSeen() { - return System.currentTimeMillis() - lastSeen; - } - - /** - * @return true if the registration lock and salt are both set. - */ - private boolean hasLockAndSalt() { - return registrationLock.isPresent() && registrationLockSalt.isPresent(); - } - - public StoredRegistrationLock(Optional registrationLock, Optional registrationLockSalt, long lastSeen) { - this.registrationLock = registrationLock; - this.registrationLockSalt = registrationLockSalt; - this.lastSeen = lastSeen; - } - - public boolean requiresClientRegistrationLock() { - boolean hasTimeRemaining = getTimeRemaining() >= 0; - return hasLockAndSalt() && hasTimeRemaining; - } - - public boolean needsFailureCredentials() { - return hasLockAndSalt(); - } - - public long getTimeRemaining() { - return TimeUnit.DAYS.toMillis(7) - timeSinceLastSeen(); - } - - public boolean verify(@Nullable String clientRegistrationLock) { - if (hasLockAndSalt() && Util.nonEmpty(clientRegistrationLock)) { - SaltedTokenHash credentials = new SaltedTokenHash(registrationLock.get(), registrationLockSalt.get()); - return credentials.verify(clientRegistrationLock); - } else { - return false; - } - } - - @VisibleForTesting - public StoredRegistrationLock forTime(long timestamp) { - return new StoredRegistrationLock(registrationLock, registrationLockSalt, timestamp); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java deleted file mode 100644 index 4976ce66f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCode.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.security.MessageDigest; -import java.time.Duration; -import javax.annotation.Nullable; -import org.whispersystems.textsecuregcm.util.Util; - -public record StoredVerificationCode(String code, - long timestamp, - String pushCode, - @Nullable byte[] sessionId) { - - public static final Duration EXPIRATION = Duration.ofMinutes(10); - - public boolean isValid(String theirCodeString) { - if (Util.isEmpty(code) || Util.isEmpty(theirCodeString)) { - return false; - } - - byte[] ourCode = code.getBytes(); - byte[] theirCode = theirCodeString.getBytes(); - - return MessageDigest.isEqual(ourCode, theirCode); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java deleted file mode 100644 index 1b6e6a469..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; - -import java.util.List; - -public class TurnToken { - - @JsonProperty - private String username; - - @JsonProperty - private String password; - - @JsonProperty - private List urls; - - public TurnToken(String username, String password, List urls) { - this.username = username; - this.password = password; - this.urls = urls; - } - - @VisibleForTesting - List getUrls() { - return urls; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnTokenGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnTokenGenerator.java deleted file mode 100644 index df6241256..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnTokenGenerator.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.Util; -import org.whispersystems.textsecuregcm.util.WeightedRandomSelect; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -public class TurnTokenGenerator { - - private final DynamicConfigurationManager dynamicConfiguration; - - public TurnTokenGenerator(final DynamicConfigurationManager config) { - this.dynamicConfiguration = config; - } - - public TurnToken generate(final String e164) { - try { - byte[] key = dynamicConfiguration.getConfiguration().getTurnConfiguration().getSecret().getBytes(); - List urls = urls(e164); - Mac mac = Mac.getInstance("HmacSHA1"); - long validUntilSeconds = (System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)) / 1000; - long user = Util.ensureNonNegativeInt(new SecureRandom().nextInt()); - String userTime = validUntilSeconds + ":" + user; - - mac.init(new SecretKeySpec(key, "HmacSHA1")); - String password = Base64.getEncoder().encodeToString(mac.doFinal(userTime.getBytes())); - - return new TurnToken(userTime, password, urls); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new AssertionError(e); - } - } - - private List urls(final String e164) { - final DynamicTurnConfiguration turnConfig = dynamicConfiguration.getConfiguration().getTurnConfiguration(); - - // Check if number is enrolled to test out specific turn servers - final Optional enrolled = turnConfig.getUriConfigs().stream() - .filter(config -> config.getEnrolledNumbers().contains(e164)) - .findFirst(); - if (enrolled.isPresent()) { - return enrolled.get().getUris(); - } - - // Otherwise, select from turn server sets by weighted choice - return WeightedRandomSelect.select(turnConfig - .getUriConfigs() - .stream() - .map(c -> new Pair, Long>(c.getUris(), c.getWeight())).toList()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksum.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksum.java deleted file mode 100644 index 915416abd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksum.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; -import java.util.Optional; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class UnidentifiedAccessChecksum { - - public static String generateFor(Optional unidentifiedAccessKey) { - try { - if (!unidentifiedAccessKey.isPresent()|| unidentifiedAccessKey.get().length != 16) return null; - - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(unidentifiedAccessKey.get(), "HmacSHA256")); - - return Base64.getEncoder().encodeToString(mac.doFinal(new byte[32])); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new AssertionError(e); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshApplicationEventListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshApplicationEventListener.java deleted file mode 100644 index ad7ffeb9c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshApplicationEventListener.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import org.glassfish.jersey.server.monitoring.ApplicationEvent; -import org.glassfish.jersey.server.monitoring.ApplicationEventListener; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.glassfish.jersey.server.monitoring.RequestEventListener; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.storage.AccountsManager; - -/** - * Delegates request events to a listener that watches for intra-request changes that require websocket refreshes - */ -public class WebsocketRefreshApplicationEventListener implements ApplicationEventListener { - - private final WebsocketRefreshRequestEventListener websocketRefreshRequestEventListener; - - public WebsocketRefreshApplicationEventListener(final AccountsManager accountsManager, - final ClientPresenceManager clientPresenceManager) { - - this.websocketRefreshRequestEventListener = new WebsocketRefreshRequestEventListener(clientPresenceManager, - new AuthEnablementRefreshRequirementProvider(accountsManager), - new PhoneNumberChangeRefreshRequirementProvider()); - } - - @Override - public void onEvent(final ApplicationEvent event) { - } - - @Override - public RequestEventListener onRequest(final RequestEvent requestEvent) { - return websocketRefreshRequestEventListener; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshRequestEventListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshRequestEventListener.java deleted file mode 100644 index 9fbb84fab..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshRequestEventListener.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import java.util.Arrays; -import java.util.concurrent.atomic.AtomicInteger; -import javax.ws.rs.container.ResourceInfo; -import javax.ws.rs.core.Context; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.glassfish.jersey.server.monitoring.RequestEvent.Type; -import org.glassfish.jersey.server.monitoring.RequestEventListener; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; - -public class WebsocketRefreshRequestEventListener implements RequestEventListener { - - private final ClientPresenceManager clientPresenceManager; - private final WebsocketRefreshRequirementProvider[] providers; - - private static final Counter DISPLACED_ACCOUNTS = Metrics.counter( - name(WebsocketRefreshRequestEventListener.class, "displacedAccounts")); - - private static final Counter DISPLACED_DEVICES = Metrics.counter( - name(WebsocketRefreshRequestEventListener.class, "displacedDevices")); - - private static final Logger logger = LoggerFactory.getLogger(WebsocketRefreshRequestEventListener.class); - - public WebsocketRefreshRequestEventListener( - final ClientPresenceManager clientPresenceManager, - final WebsocketRefreshRequirementProvider... providers) { - - this.clientPresenceManager = clientPresenceManager; - this.providers = providers; - } - - @Context - private ResourceInfo resourceInfo; - - @Override - public void onEvent(final RequestEvent event) { - if (event.getType() == Type.REQUEST_FILTERED) { - for (final WebsocketRefreshRequirementProvider provider : providers) { - provider.handleRequestFiltered(event); - } - } else if (event.getType() == Type.FINISHED) { - final AtomicInteger displacedDevices = new AtomicInteger(0); - - Arrays.stream(providers) - .flatMap(provider -> provider.handleRequestFinished(event).stream()) - .distinct() - .forEach(pair -> { - try { - displacedDevices.incrementAndGet(); - clientPresenceManager.disconnectPresence(pair.first(), pair.second()); - } catch (final Exception e) { - logger.error("Could not displace device presence", e); - } - }); - - if (displacedDevices.get() > 0) { - DISPLACED_ACCOUNTS.increment(); - DISPLACED_DEVICES.increment(displacedDevices.get()); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshRequirementProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshRequirementProvider.java deleted file mode 100644 index 2f75127ec..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/WebsocketRefreshRequirementProvider.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import java.util.List; -import java.util.UUID; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.whispersystems.textsecuregcm.util.Pair; - -/** - * A websocket refresh requirement provider watches for intra-request changes (e.g. to authentication status) that - * require a websocket refresh. - */ -public interface WebsocketRefreshRequirementProvider { - - /** - * Processes a request after filters have run and the request has been mapped to a destination controller. - * - * @param requestEvent the request event to observe - */ - void handleRequestFiltered(RequestEvent requestEvent); - - /** - * Processes a request after all normal request handling has been completed. - * - * @param requestEvent the request event to observe - * @return a list of pairs of account UUID/device ID pairs identifying websockets that need to be refreshed as a - * result of the observed request - */ - List> handleRequestFinished(RequestEvent requestEvent); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/badges/BadgeTranslator.java b/service/src/main/java/org/whispersystems/textsecuregcm/badges/BadgeTranslator.java deleted file mode 100644 index 0e5d7cf6a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/badges/BadgeTranslator.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.badges; - -import java.util.List; -import java.util.Locale; -import org.whispersystems.textsecuregcm.entities.Badge; - -public interface BadgeTranslator { - Badge translate(List acceptableLanguages, String badgeId); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java b/service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java deleted file mode 100644 index 40a9f2dcc..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.badges; - -import com.google.common.annotations.VisibleForTesting; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.ResourceBundle; -import java.util.function.Function; -import java.util.stream.Collectors; -import org.signal.i18n.HeaderControlledResourceBundleLookup; -import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; -import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.entities.Badge; -import org.whispersystems.textsecuregcm.entities.BadgeSvg; -import org.whispersystems.textsecuregcm.entities.SelfBadge; -import org.whispersystems.textsecuregcm.storage.AccountBadge; - -public class ConfiguredProfileBadgeConverter implements ProfileBadgeConverter, BadgeTranslator { - - @VisibleForTesting - static final String BASE_NAME = "org.signal.badges.Badges"; - - private final Clock clock; - private final Map knownBadges; - private final List badgeIdsEnabledForAll; - private final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup; - - public ConfiguredProfileBadgeConverter( - final Clock clock, - final BadgesConfiguration badgesConfiguration, - final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup) { - this.clock = clock; - this.knownBadges = badgesConfiguration.getBadges().stream() - .collect(Collectors.toMap(BadgeConfiguration::getId, Function.identity())); - this.badgeIdsEnabledForAll = badgesConfiguration.getBadgeIdsEnabledForAll(); - this.headerControlledResourceBundleLookup = headerControlledResourceBundleLookup; - } - - @Override - public Badge translate(final List acceptableLanguages, final String badgeId) { - final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME, - acceptableLanguages); - final BadgeConfiguration configuration = knownBadges.get(badgeId); - return newBadge( - false, - configuration.getId(), - configuration.getCategory(), - resourceBundle.getString(configuration.getId() + "_name"), - resourceBundle.getString(configuration.getId() + "_description"), - configuration.getSprites(), - configuration.getSvg(), - configuration.getSvgs(), - null, - false); - } - - @Override - public List convert( - final List acceptableLanguages, - final List accountBadges, - final boolean isSelf) { - if (accountBadges.isEmpty() && badgeIdsEnabledForAll.isEmpty()) { - return List.of(); - } - - final Instant now = clock.instant(); - final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME, - acceptableLanguages); - List badges = accountBadges.stream() - .filter(accountBadge -> (isSelf || accountBadge.isVisible()) - && now.isBefore(accountBadge.getExpiration()) - && knownBadges.containsKey(accountBadge.getId())) - .map(accountBadge -> { - BadgeConfiguration configuration = knownBadges.get(accountBadge.getId()); - return newBadge( - isSelf, - accountBadge.getId(), - configuration.getCategory(), - resourceBundle.getString(accountBadge.getId() + "_name"), - resourceBundle.getString(accountBadge.getId() + "_description"), - configuration.getSprites(), - configuration.getSvg(), - configuration.getSvgs(), - accountBadge.getExpiration(), - accountBadge.isVisible()); - }) - .collect(Collectors.toCollection(ArrayList::new)); - badges.addAll(badgeIdsEnabledForAll.stream().filter(knownBadges::containsKey).map(id -> { - BadgeConfiguration configuration = knownBadges.get(id); - return newBadge( - isSelf, - id, - configuration.getCategory(), - resourceBundle.getString(id + "_name"), - resourceBundle.getString(id + "_description"), - configuration.getSprites(), - configuration.getSvg(), - configuration.getSvgs(), - now.plus(Duration.ofDays(1)), - true); - }).collect(Collectors.toList())); - return badges; - } - - private Badge newBadge( - final boolean isSelf, - final String id, - final String category, - final String name, - final String description, - final List sprites, - final String svg, - final List svgs, - final Instant expiration, - final boolean visible) { - if (isSelf) { - return new SelfBadge(id, category, name, description, sprites, svg, svgs, expiration, visible); - } else { - return new Badge(id, category, name, description, sprites, svg, svgs); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/badges/LevelTranslator.java b/service/src/main/java/org/whispersystems/textsecuregcm/badges/LevelTranslator.java deleted file mode 100644 index a9529b88d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/badges/LevelTranslator.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.badges; - -import java.util.List; -import java.util.Locale; - -public interface LevelTranslator { - String translate(List acceptableLanguages, String badgeId); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ProfileBadgeConverter.java b/service/src/main/java/org/whispersystems/textsecuregcm/badges/ProfileBadgeConverter.java deleted file mode 100644 index b36f8946f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ProfileBadgeConverter.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.badges; - -import java.util.List; -import java.util.Locale; -import org.whispersystems.textsecuregcm.entities.Badge; -import org.whispersystems.textsecuregcm.storage.AccountBadge; - -public interface ProfileBadgeConverter { - - /** - * Converts the {@link AccountBadge}s for an account into the objects - * that can be returned on a profile fetch. - */ - List convert(List acceptableLanguages, List accountBadges, boolean isSelf); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ResourceBundleLevelTranslator.java b/service/src/main/java/org/whispersystems/textsecuregcm/badges/ResourceBundleLevelTranslator.java deleted file mode 100644 index 807a1d537..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/badges/ResourceBundleLevelTranslator.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.badges; - -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.ResourceBundle; -import javax.annotation.Nonnull; -import org.signal.i18n.HeaderControlledResourceBundleLookup; - -public class ResourceBundleLevelTranslator implements LevelTranslator { - - private static final String BASE_NAME = "org.signal.subscriptions.Subscriptions"; - - private final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup; - - public ResourceBundleLevelTranslator( - @Nonnull final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup) { - this.headerControlledResourceBundleLookup = Objects.requireNonNull(headerControlledResourceBundleLookup); - } - - @Override - public String translate(final List acceptableLanguages, final String badgeId) { - final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME, - acceptableLanguages); - return resourceBundle.getString(badgeId); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java deleted file mode 100644 index fbf0486d4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.captcha; - -/** - * A captcha assessment - * - * @param valid whether the captcha was passed - * @param score string representation of the risk level - */ -public record AssessmentResult(boolean valid, String score) { - - public static AssessmentResult invalid() { - return new AssessmentResult(false, ""); - } - - /** - * Map a captcha score in [0.0, 1.0] to a low cardinality discrete space in [0, 100] suitable for use in metrics - */ - static String scoreString(final float score) { - final int x = Math.round(score * 10); // [0, 10] - return Integer.toString(x * 10); // [0, 100] in increments of 10 - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java deleted file mode 100644 index 4a3603aa9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.captcha; - -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import javax.ws.rs.BadRequestException; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -public class CaptchaChecker { - private static final String ASSESSMENTS_COUNTER_NAME = name(RecaptchaClient.class, "assessments"); - - @VisibleForTesting - static final String SEPARATOR = "."; - - private final Map captchaClientMap; - - public CaptchaChecker(final List captchaClients) { - this.captchaClientMap = captchaClients.stream() - .collect(Collectors.toMap(CaptchaClient::scheme, Function.identity())); - } - - /** - * Check if a solved captcha should be accepted - *

- * - * @param input expected to contain a prefix indicating the captcha scheme, sitekey, token, and action. The expected - * format is {@code version-prefix.sitekey.[action.]token} - * @param ip IP of the solver - * @return An {@link AssessmentResult} indicating whether the solution should be accepted, and a score that can be - * used for metrics - * @throws IOException if there is an error validating the captcha with the underlying service - * @throws BadRequestException if input is not in the expected format - */ - public AssessmentResult verify(final String input, final String ip) throws IOException { - /* - * For action to be optional, there is a strong assumption that the token will never contain a {@value SEPARATOR}. - * Observation suggests {@code token} is base-64 encoded. In practice, an action should always be present, but we - * don’t need to be strict. - */ - final String[] parts = input.split("\\" + SEPARATOR, 4); - - // we allow missing actions, if we're missing 1 part, assume it's the action - if (parts.length < 3) { - throw new BadRequestException("too few parts"); - } - - int idx = 0; - final String prefix = parts[idx++]; - final String siteKey = parts[idx++]; - final String action = parts.length == 3 ? null : parts[idx++]; - final String token = parts[idx]; - - final CaptchaClient client = this.captchaClientMap.get(prefix); - if (client == null) { - throw new BadRequestException("invalid captcha scheme"); - } - final AssessmentResult result = client.verify(siteKey, action, token, ip); - Metrics.counter(ASSESSMENTS_COUNTER_NAME, - "action", String.valueOf(action), - "valid", String.valueOf(result.valid()), - "score", result.score(), - "provider", prefix) - .increment(); - return result; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java deleted file mode 100644 index 3c8fd5a2a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.captcha; - -import javax.annotation.Nullable; -import java.io.IOException; - -public interface CaptchaClient { - - /** - * @return the identifying captcha scheme that this CaptchaClient handles - */ - String scheme(); - - /** - * Verify a provided captcha solution - * - * @param siteKey identifying string for the captcha service - * @param action an optional action indicating the purpose of the captcha - * @param token the captcha solution that will be verified - * @param ip the ip of the captcha solve - * @return An {@link AssessmentResult} indicating whether the solution should be accepted - * @throws IOException if the underlying captcha provider returns an error - */ - AssessmentResult verify( - final String siteKey, - final @Nullable String action, - final String token, - final String ip) throws IOException; -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java deleted file mode 100644 index 3f2583ff7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClient.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.captcha; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import io.micrometer.core.instrument.Metrics; -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import javax.annotation.Nullable; -import javax.ws.rs.core.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -public class HCaptchaClient implements CaptchaClient { - - private static final Logger logger = LoggerFactory.getLogger(HCaptchaClient.class); - private static final String PREFIX = "signal-hcaptcha"; - private static final String ASSESSMENT_REASON_COUNTER_NAME = name(HCaptchaClient.class, "assessmentReason"); - private static final String INVALID_REASON_COUNTER_NAME = name(HCaptchaClient.class, "invalidReason"); - private final String apiKey; - private final HttpClient client; - private final DynamicConfigurationManager dynamicConfigurationManager; - - public HCaptchaClient( - final String apiKey, - final HttpClient client, - final DynamicConfigurationManager dynamicConfigurationManager) { - this.apiKey = apiKey; - this.client = client; - this.dynamicConfigurationManager = dynamicConfigurationManager; - } - - @Override - public String scheme() { - return PREFIX; - } - - @Override - public AssessmentResult verify(final String siteKey, final @Nullable String action, final String token, - final String ip) - throws IOException { - - final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration(); - if (!config.isAllowHCaptcha()) { - logger.warn("Received request to verify an hCaptcha, but hCaptcha is not enabled"); - return AssessmentResult.invalid(); - } - - final String body = String.format("response=%s&secret=%s&remoteip=%s", - URLEncoder.encode(token, StandardCharsets.UTF_8), - URLEncoder.encode(this.apiKey, StandardCharsets.UTF_8), - ip); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("https://hcaptcha.com/siteverify")) - .header("Content-Type", "application/x-www-form-urlencoded") - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); - - HttpResponse response; - try { - response = this.client.send(request, HttpResponse.BodyHandlers.ofString()); - } catch (InterruptedException e) { - throw new IOException(e); - } - - if (response.statusCode() != Response.Status.OK.getStatusCode()) { - logger.warn("failure submitting token to hCaptcha (code={}): {}", response.statusCode(), response); - throw new IOException("hCaptcha http failure : " + response.statusCode()); - } - - final HCaptchaResponse hCaptchaResponse = SystemMapper.getMapper() - .readValue(response.body(), HCaptchaResponse.class); - - logger.debug("received hCaptcha response: {}", hCaptchaResponse); - - if (!hCaptchaResponse.success) { - for (String errorCode : hCaptchaResponse.errorCodes) { - Metrics.counter(INVALID_REASON_COUNTER_NAME, - "action", String.valueOf(action), - "reason", errorCode).increment(); - } - return AssessmentResult.invalid(); - } - - // hcaptcha uses the inverse scheme of recaptcha (for hcaptcha, a low score is less risky) - float score = 1.0f - hCaptchaResponse.score; - if (score < 0.0f || score > 1.0f) { - logger.error("Invalid score {} from hcaptcha response {}", hCaptchaResponse.score, hCaptchaResponse); - return AssessmentResult.invalid(); - } - final String scoreString = AssessmentResult.scoreString(score); - - for (String reason : hCaptchaResponse.scoreReasons) { - Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, - "action", String.valueOf(action), - "reason", reason, - "score", scoreString).increment(); - } - return new AssessmentResult(score >= config.getScoreFloor().floatValue(), scoreString); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaResponse.java deleted file mode 100644 index 39db8c755..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/HCaptchaResponse.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.captcha; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.time.Duration; -import java.util.Collections; -import java.util.List; - -/** - * Verify response returned by hcaptcha - *

- * see ... - */ -public class HCaptchaResponse { - - @JsonProperty - boolean success; - - @JsonProperty(value = "challenge-ts") - Duration challengeTs; - - @JsonProperty - String hostname; - - @JsonProperty - boolean credit; - - @JsonProperty(value = "error-codes") - List errorCodes = Collections.emptyList(); - - @JsonProperty - float score; - - @JsonProperty(value = "score-reasons") - List scoreReasons = Collections.emptyList(); - - public HCaptchaResponse() { - } - - @Override - public String toString() { - return "HCaptchaResponse{" + - "success=" + success + - ", challengeTs=" + challengeTs + - ", hostname='" + hostname + '\'' + - ", credit=" + credit + - ", errorCodes=" + errorCodes + - ", score=" + score + - ", scoreReasons=" + scoreReasons + - '}'; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java deleted file mode 100644 index d53931b33..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/RecaptchaClient.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.captcha; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.api.gax.core.FixedCredentialsProvider; -import com.google.api.gax.rpc.ApiException; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceClient; -import com.google.cloud.recaptchaenterprise.v1.RecaptchaEnterpriseServiceSettings; -import com.google.recaptchaenterprise.v1.Assessment; -import com.google.recaptchaenterprise.v1.Event; -import com.google.recaptchaenterprise.v1.RiskAnalysis; -import io.micrometer.core.instrument.Metrics; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Objects; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -public class RecaptchaClient implements CaptchaClient { - - private static final Logger log = LoggerFactory.getLogger(RecaptchaClient.class); - - private static final String V2_PREFIX = "signal-recaptcha-v2"; - private static final String INVALID_REASON_COUNTER_NAME = name(RecaptchaClient.class, "invalidReason"); - private static final String ASSESSMENT_REASON_COUNTER_NAME = name(RecaptchaClient.class, "assessmentReason"); - - private final String projectPath; - private final RecaptchaEnterpriseServiceClient client; - private final DynamicConfigurationManager dynamicConfigurationManager; - - public RecaptchaClient( - @Nonnull final String projectPath, - @Nonnull final String recaptchaCredentialConfigurationJson, - final DynamicConfigurationManager dynamicConfigurationManager) { - try { - this.projectPath = Objects.requireNonNull(projectPath); - this.client = RecaptchaEnterpriseServiceClient.create(RecaptchaEnterpriseServiceSettings.newBuilder() - .setCredentialsProvider(FixedCredentialsProvider.create(GoogleCredentials.fromStream( - new ByteArrayInputStream(recaptchaCredentialConfigurationJson.getBytes(StandardCharsets.UTF_8))))) - .build()); - - this.dynamicConfigurationManager = dynamicConfigurationManager; - } catch (IOException e) { - throw new AssertionError(e); - } - } - - @Override - public String scheme() { - return V2_PREFIX; - } - - @Override - public org.whispersystems.textsecuregcm.captcha.AssessmentResult verify(final String sitekey, - final @Nullable String expectedAction, - final String token, final String ip) throws IOException { - final DynamicCaptchaConfiguration config = dynamicConfigurationManager.getConfiguration().getCaptchaConfiguration(); - if (!config.isAllowRecaptcha()) { - log.warn("Received request to verify a recaptcha, but recaptcha is not enabled"); - return AssessmentResult.invalid(); - } - - Event.Builder eventBuilder = Event.newBuilder() - .setSiteKey(sitekey) - .setToken(token) - .setUserIpAddress(ip); - - if (expectedAction != null) { - eventBuilder.setExpectedAction(expectedAction); - } - - final Event event = eventBuilder.build(); - final Assessment assessment; - try { - assessment = client.createAssessment(projectPath, Assessment.newBuilder().setEvent(event).build()); - } catch (ApiException e) { - throw new IOException(e); - } - - if (assessment.getTokenProperties().getValid()) { - final float score = assessment.getRiskAnalysis().getScore(); - log.debug("assessment for {} was valid, score: {}", expectedAction, score); - for (RiskAnalysis.ClassificationReason reason : assessment.getRiskAnalysis().getReasonsList()) { - Metrics.counter(ASSESSMENT_REASON_COUNTER_NAME, - "action", String.valueOf(expectedAction), - "score", AssessmentResult.scoreString(score), - "reason", reason.name()) - .increment(); - } - return new AssessmentResult( - score >= config.getScoreFloor().floatValue(), - AssessmentResult.scoreString(score)); - } else { - Metrics.counter(INVALID_REASON_COUNTER_NAME, - "action", String.valueOf(expectedAction), - "reason", assessment.getTokenProperties().getInvalidReason().name()) - .increment(); - return AssessmentResult.invalid(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AccountDatabaseCrawlerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AccountDatabaseCrawlerConfiguration.java deleted file mode 100644 index 317418139..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AccountDatabaseCrawlerConfiguration.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class AccountDatabaseCrawlerConfiguration { - - @JsonProperty - private int chunkSize = 1000; - - @JsonProperty - private long chunkIntervalMs = 8000L; - - public int getChunkSize() { - return chunkSize; - } - - public long getChunkIntervalMs() { - return chunkIntervalMs; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AccountsTableConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AccountsTableConfiguration.java deleted file mode 100644 index 21eee6c8d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AccountsTableConfiguration.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import org.whispersystems.textsecuregcm.configuration.DynamoDbTables.Table; -import javax.validation.constraints.NotBlank; - -public class AccountsTableConfiguration extends Table { - - private final String phoneNumberTableName; - private final String phoneNumberIdentifierTableName; - private final String usernamesTableName; - private final int scanPageSize; - - @JsonCreator - public AccountsTableConfiguration( - @JsonProperty("tableName") final String tableName, - @JsonProperty("phoneNumberTableName") final String phoneNumberTableName, - @JsonProperty("phoneNumberIdentifierTableName") final String phoneNumberIdentifierTableName, - @JsonProperty("usernamesTableName") final String usernamesTableName, - @JsonProperty("scanPageSize") final int scanPageSize) { - - super(tableName); - - this.phoneNumberTableName = phoneNumberTableName; - this.phoneNumberIdentifierTableName = phoneNumberIdentifierTableName; - this.usernamesTableName = usernamesTableName; - this.scanPageSize = scanPageSize; - } - - @NotBlank - public String getPhoneNumberTableName() { - return phoneNumberTableName; - } - - @NotBlank - public String getPhoneNumberIdentifierTableName() { - return phoneNumberIdentifierTableName; - } - - @NotBlank - public String getUsernamesTableName() { - return usernamesTableName; - } - - public int getScanPageSize() { - return scanPageSize; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AdminEventLoggingConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AdminEventLoggingConfiguration.java deleted file mode 100644 index 18fca6dc6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AdminEventLoggingConfiguration.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import javax.validation.constraints.NotEmpty; - -public record AdminEventLoggingConfiguration( - @NotEmpty String credentials, - @NotEmpty String projectId, - @NotEmpty String logName) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java deleted file mode 100644 index 131ad053b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; - - -public class ApnConfiguration { - - @NotEmpty - @JsonProperty - private String teamId; - - @NotEmpty - @JsonProperty - private String keyId; - - @NotEmpty - @JsonProperty - private String signingKey; - - @NotEmpty - @JsonProperty - private String bundleId; - - @JsonProperty - private boolean sandbox = false; - - public String getTeamId() { - return teamId; - } - - public String getKeyId() { - return keyId; - } - - public String getSigningKey() { - return signingKey; - } - - public String getBundleId() { - return bundleId; - } - - public boolean isSandboxEnabled() { - return sandbox; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppConfigConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppConfigConfiguration.java deleted file mode 100644 index 1f4d08c0e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppConfigConfiguration.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.constraints.NotEmpty; - -public class AppConfigConfiguration { - - @JsonProperty - @NotEmpty - private String application; - - @JsonProperty - @NotEmpty - private String environment; - - @JsonProperty - @NotEmpty - private String configuration; - - public String getApplication() { - return application; - } - - public String getEnvironment() { - return environment; - } - - public String getConfigurationName() { - return configuration; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ArtServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ArtServiceConfiguration.java deleted file mode 100644 index f0cea1d9f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ArtServiceConfiguration.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Duration; -import java.util.HexFormat; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class ArtServiceConfiguration { - - @NotEmpty - @JsonProperty - private String userAuthenticationTokenSharedSecret; - - @NotEmpty - @JsonProperty - private String userAuthenticationTokenUserIdSecret; - - @JsonProperty - @NotNull - private Duration tokenExpiration = Duration.ofDays(1); - - public byte[] getUserAuthenticationTokenSharedSecret() { - return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret); - } - - public byte[] getUserAuthenticationTokenUserIdSecret() { - return HexFormat.of().parseHex(userAuthenticationTokenUserIdSecret); - } - - public Duration getTokenExpiration() { - return tokenExpiration; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AwsAttachmentsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AwsAttachmentsConfiguration.java deleted file mode 100644 index b1d98271e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/AwsAttachmentsConfiguration.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; - -public class AwsAttachmentsConfiguration { - - @NotEmpty - @JsonProperty - private String accessKey; - - @NotEmpty - @JsonProperty - private String accessSecret; - - @NotEmpty - @JsonProperty - private String bucket; - - @NotEmpty - @JsonProperty - private String region; - - public String getAccessKey() { - return accessKey; - } - - public String getAccessSecret() { - return accessSecret; - } - - public String getBucket() { - return bucket; - } - - public String getRegion() { - return region; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgeConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgeConfiguration.java deleted file mode 100644 index 8fe806b74..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgeConfiguration.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import org.whispersystems.textsecuregcm.entities.BadgeSvg; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public class BadgeConfiguration { - public static final String CATEGORY_TESTING = "testing"; - - private final String id; - private final String category; - private final List sprites; - private final String svg; - private final List svgs; - - @JsonCreator - public BadgeConfiguration( - @JsonProperty("id") final String id, - @JsonProperty("category") final String category, - @JsonProperty("sprites") final List sprites, - @JsonProperty("svg") final String svg, - @JsonProperty("svgs") final List svgs) { - this.id = id; - this.category = category; - this.sprites = sprites; - this.svg = svg; - this.svgs = svgs; - } - - @NotEmpty - public String getId() { - return id; - } - - @NotEmpty - public String getCategory() { - return category; - } - - @NotNull - @ExactlySize(6) - public List getSprites() { - return sprites; - } - - @NotEmpty - public String getSvg() { - return svg; - } - - @NotNull - public List getSvgs() { - return svgs; - } - - public boolean isTestBadge() { - return CATEGORY_TESTING.equals(category); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgesConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgesConfiguration.java deleted file mode 100644 index dd067bf7a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgesConfiguration.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonSetter; -import com.fasterxml.jackson.annotation.Nulls; -import io.dropwizard.validation.ValidationMethod; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; - -public class BadgesConfiguration { - private final List badges; - private final List badgeIdsEnabledForAll; - private final Map receiptLevels; - - @JsonCreator - public BadgesConfiguration( - @JsonProperty("badges") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badges, - @JsonProperty("badgeIdsEnabledForAll") @JsonSetter(nulls = Nulls.AS_EMPTY) final List badgeIdsEnabledForAll, - @JsonProperty("receiptLevels") @JsonSetter(nulls = Nulls.AS_EMPTY) final Map receiptLevels) { - this.badges = Objects.requireNonNull(badges); - this.badgeIdsEnabledForAll = Objects.requireNonNull(badgeIdsEnabledForAll); - this.receiptLevels = Objects.requireNonNull(receiptLevels); - } - - @Valid - @NotNull - public List getBadges() { - return badges; - } - - @Valid - @NotNull - public List getBadgeIdsEnabledForAll() { - return badgeIdsEnabledForAll; - } - - @Valid - @NotNull - public Map getReceiptLevels() { - return receiptLevels; - } - - @JsonIgnore - @ValidationMethod(message = "contains receipt level mappings that are not configured badges") - public boolean isAllReceiptLevelsConfigured() { - final Set badgeNames = badges.stream().map(BadgeConfiguration::getId).collect(Collectors.toSet()); - return badgeNames.containsAll(receiptLevels.values()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BraintreeConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BraintreeConfiguration.java deleted file mode 100644 index 388dcacbd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/BraintreeConfiguration.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import java.util.Map; -import java.util.Set; -import javax.validation.Valid; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -/** - * @param merchantId the Braintree merchant ID - * @param publicKey the Braintree API public key - * @param privateKey the Braintree API private key - * @param environment the Braintree environment ("production" or "sandbox") - * @param supportedCurrencies the set of supported currencies - * @param graphqlUrl the Braintree GraphQL URl to use (this must match the environment) - * @param merchantAccounts merchant account within the merchant for processing individual currencies - * @param circuitBreaker configuration for the circuit breaker used by the GraphQL HTTP client - */ -public record BraintreeConfiguration(@NotBlank String merchantId, - @NotBlank String publicKey, - @NotBlank String privateKey, - @NotBlank String environment, - @NotEmpty Set<@NotBlank String> supportedCurrencies, - @NotBlank String graphqlUrl, - @NotEmpty Map merchantAccounts, - @NotNull - @Valid - CircuitBreakerConfiguration circuitBreaker) { - - public BraintreeConfiguration { - if (circuitBreaker == null) { - // It’s a little counter-intuitive, but this compact constructor allows a default value - // to be used when one isn’t specified (e.g. in YAML), allowing the field to still be - // validated as @NotNull - circuitBreaker = new CircuitBreakerConfiguration(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CdnConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CdnConfiguration.java deleted file mode 100644 index 910d59fdd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CdnConfiguration.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; - -public class CdnConfiguration { - @NotEmpty - @JsonProperty - private String accessKey; - - @NotEmpty - @JsonProperty - private String accessSecret; - - @NotEmpty - @JsonProperty - private String bucket; - - @NotEmpty - @JsonProperty - private String region; - - public String getAccessKey() { - return accessKey; - } - - public String getAccessSecret() { - return accessSecret; - } - - public String getBucket() { - return bucket; - } - - public String getRegion() { - return region; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CircuitBreakerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CircuitBreakerConfiguration.java deleted file mode 100644 index 2fd70bec1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CircuitBreakerConfiguration.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; -import java.time.Duration; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotNull; - -public class CircuitBreakerConfiguration { - - @JsonProperty - @NotNull - @Min(1) - @Max(100) - private int failureRateThreshold = 50; - - @JsonProperty - @NotNull - @Min(1) - private int permittedNumberOfCallsInHalfOpenState = 10; - - @JsonProperty - @NotNull - @Min(1) - private int slidingWindowSize = 100; - - @JsonProperty - @NotNull - @Min(1) - private int slidingWindowMinimumNumberOfCalls = 100; - - @JsonProperty - @NotNull - @Min(1) - private long waitDurationInOpenStateInSeconds = 10; - - @JsonProperty - private List ignoredExceptions = Collections.emptyList(); - - - public int getFailureRateThreshold() { - return failureRateThreshold; - } - - public int getPermittedNumberOfCallsInHalfOpenState() { - return permittedNumberOfCallsInHalfOpenState; - } - - public int getSlidingWindowSize() { - return slidingWindowSize; - } - - public int getSlidingWindowMinimumNumberOfCalls() { - return slidingWindowMinimumNumberOfCalls; - } - - public long getWaitDurationInOpenStateInSeconds() { - return waitDurationInOpenStateInSeconds; - } - - public List> getIgnoredExceptions() { - return ignoredExceptions.stream() - .map(name -> { - try { - return Class.forName(name); - } catch (final ClassNotFoundException e) { - throw new RuntimeException(e); - } - }) - .collect(Collectors.toList()); - } - - @VisibleForTesting - public void setFailureRateThreshold(int failureRateThreshold) { - this.failureRateThreshold = failureRateThreshold; - } - - @VisibleForTesting - public void setSlidingWindowSize(int size) { - this.slidingWindowSize = size; - } - - @VisibleForTesting - public void setSlidingWindowMinimumNumberOfCalls(int size) { - this.slidingWindowMinimumNumberOfCalls = size; - } - - @VisibleForTesting - public void setPermittedNumberOfCallsInHalfOpenState(int size) { - this.permittedNumberOfCallsInHalfOpenState = size; - } - - @VisibleForTesting - public void setWaitDurationInOpenStateInSeconds(int seconds) { - this.waitDurationInOpenStateInSeconds = seconds; - } - - @VisibleForTesting - public void setIgnoredExceptions(final List ignoredExceptions) { - this.ignoredExceptions = ignoredExceptions; - } - - public CircuitBreakerConfig toCircuitBreakerConfig() { - return CircuitBreakerConfig.custom() - .failureRateThreshold(getFailureRateThreshold()) - .ignoreExceptions(getIgnoredExceptions().toArray(new Class[0])) - .permittedNumberOfCallsInHalfOpenState(getPermittedNumberOfCallsInHalfOpenState()) - .waitDurationInOpenState(Duration.ofSeconds(getWaitDurationInOpenStateInSeconds())) - .slidingWindow(getSlidingWindowSize(), getSlidingWindowMinimumNumberOfCalls(), - CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) - .build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DatabaseConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DatabaseConfiguration.java deleted file mode 100644 index 0b7cfcb17..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DatabaseConfiguration.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.constraints.NotNull; - -import io.dropwizard.db.DataSourceFactory; - -public class DatabaseConfiguration extends DataSourceFactory { - - @NotNull - @JsonProperty - private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration(); - - public CircuitBreakerConfiguration getCircuitBreakerConfiguration() { - return circuitBreaker; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DatadogConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DatadogConfiguration.java deleted file mode 100644 index bed91c501..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DatadogConfiguration.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.micrometer.datadog.DatadogConfig; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import java.time.Duration; - -public class DatadogConfiguration implements DatadogConfig { - - @JsonProperty - @NotBlank - private String apiKey; - - @JsonProperty - @NotNull - private Duration step = Duration.ofSeconds(10); - - @JsonProperty - @NotBlank - private String environment; - - @JsonProperty - @Min(1) - private int batchSize = 5_000; - - @Override - public String apiKey() { - return apiKey; - } - - @Override - public Duration step() { - return step; - } - - public String getEnvironment() { - return environment; - } - - @Override - public int batchSize() { - return batchSize; - } - - @Override - public String hostTag() { - return "host"; - } - - @Override - public String get(final String key) { - return null; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeletedAccountsTableConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeletedAccountsTableConfiguration.java deleted file mode 100644 index 88d621aec..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeletedAccountsTableConfiguration.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotBlank; -import org.whispersystems.textsecuregcm.configuration.DynamoDbTables.Table; - -public class DeletedAccountsTableConfiguration extends Table { - - private final String needsReconciliationIndexName; - - @JsonCreator - public DeletedAccountsTableConfiguration( - @JsonProperty("tableName") final String tableName, - @JsonProperty("needsReconciliationIndexName") final String needsReconciliationIndexName) { - - super(tableName); - this.needsReconciliationIndexName = needsReconciliationIndexName; - } - - @NotBlank - public String getNeedsReconciliationIndexName() { - return needsReconciliationIndexName; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryClientConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryClientConfiguration.java deleted file mode 100644 index d612a4b07..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryClientConfiguration.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.HexFormat; -import javax.validation.constraints.NotEmpty; - -public class DirectoryClientConfiguration { - - @NotEmpty - @JsonProperty - private String userAuthenticationTokenSharedSecret; - - @NotEmpty - @JsonProperty - private String userAuthenticationTokenUserIdSecret; - - public byte[] getUserAuthenticationTokenSharedSecret() { - return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret); - } - - public byte[] getUserAuthenticationTokenUserIdSecret() { - return HexFormat.of().parseHex(userAuthenticationTokenUserIdSecret); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryConfiguration.java deleted file mode 100644 index 9058fc1f9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryConfiguration.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import java.util.List; - -public class DirectoryConfiguration { - - @JsonProperty - @NotNull - @Valid - private SqsConfiguration sqs; - - @JsonProperty - @NotNull - @Valid - private DirectoryClientConfiguration client; - - @JsonProperty - @NotNull - @Valid - private List server; - - public SqsConfiguration getSqsConfiguration() { - return sqs; - } - - public DirectoryClientConfiguration getDirectoryClientConfiguration() { - return client; - } - - public List getDirectoryServerConfiguration() { - return server; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryServerConfiguration.java deleted file mode 100644 index 44942df29..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryServerConfiguration.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; -import java.util.List; - -public class DirectoryServerConfiguration { - - @NotEmpty - @JsonProperty - private String replicationName; - - @NotEmpty - @JsonProperty - private String replicationUrl; - - @NotEmpty - @JsonProperty - private String replicationPassword; - - @NotEmpty - @JsonProperty - private List<@NotBlank String> replicationCaCertificates; - - public String getReplicationName() { - return replicationName; - } - - public String getReplicationUrl() { - return replicationUrl; - } - - public String getReplicationPassword() { - return replicationPassword; - } - - public List getReplicationCaCertificates() { - return replicationCaCertificates; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2ClientConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2ClientConfiguration.java deleted file mode 100644 index 61965df5d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2ClientConfiguration.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public record DirectoryV2ClientConfiguration(@ExactlySize({32}) byte[] userAuthenticationTokenSharedSecret, - @ExactlySize({32}) byte[] userIdTokenSharedSecret) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2Configuration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2Configuration.java deleted file mode 100644 index afe069ca9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2Configuration.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.Valid; - -public class DirectoryV2Configuration { - - private final DirectoryV2ClientConfiguration clientConfiguration; - - @JsonCreator - public DirectoryV2Configuration(@JsonProperty("client") DirectoryV2ClientConfiguration clientConfiguration) { - this.clientConfiguration = clientConfiguration; - } - - @Valid - public DirectoryV2ClientConfiguration getDirectoryV2ClientConfiguration() { - return clientConfiguration; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbClientConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbClientConfiguration.java deleted file mode 100644 index 3a7c84a7c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbClientConfiguration.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Duration; -import javax.validation.constraints.NotEmpty; - -public class DynamoDbClientConfiguration { - - private final String region; - private final Duration clientExecutionTimeout; - private final Duration clientRequestTimeout; - - @JsonCreator - public DynamoDbClientConfiguration( - @JsonProperty("region") final String region, - @JsonProperty("clientExcecutionTimeout") final Duration clientExecutionTimeout, - @JsonProperty("clientRequestTimeout") final Duration clientRequestTimeout) { - this.region = region; - this.clientExecutionTimeout = clientExecutionTimeout != null ? clientExecutionTimeout : Duration.ofSeconds(30); - this.clientRequestTimeout = clientRequestTimeout != null ? clientRequestTimeout : Duration.ofSeconds(10); - } - - @NotEmpty - public String getRegion() { - return region; - } - - public Duration getClientExecutionTimeout() { - return clientExecutionTimeout; - } - - public Duration getClientRequestTimeout() { - return clientRequestTimeout; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java deleted file mode 100644 index 14c4ea3dc..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Duration; -import javax.validation.Valid; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class DynamoDbTables { - - public static class Table { - private final String tableName; - - @JsonCreator - public Table( - @JsonProperty("tableName") final String tableName) { - this.tableName = tableName; - } - - @NotEmpty - public String getTableName() { - return tableName; - } - } - - public static class TableWithExpiration extends Table { - private final Duration expiration; - - @JsonCreator - public TableWithExpiration( - @JsonProperty("tableName") final String tableName, - @JsonProperty("expiration") final Duration expiration) { - super(tableName); - this.expiration = expiration; - } - - @NotNull - public Duration getExpiration() { - return expiration; - } - } - - private final AccountsTableConfiguration accounts; - private final DeletedAccountsTableConfiguration deletedAccounts; - private final Table deletedAccountsLock; - private final IssuedReceiptsTableConfiguration issuedReceipts; - private final Table keys; - private final TableWithExpiration messages; - private final Table pendingAccounts; - private final Table pendingDevices; - private final Table phoneNumberIdentifiers; - private final Table profiles; - private final Table pushChallenge; - private final TableWithExpiration redeemedReceipts; - private final Table remoteConfig; - private final Table reportMessage; - private final Table reservedUsernames; - private final Table subscriptions; - private final TableWithExpiration registrationRecovery; - - public DynamoDbTables( - @JsonProperty("accounts") final AccountsTableConfiguration accounts, - @JsonProperty("deletedAccounts") final DeletedAccountsTableConfiguration deletedAccounts, - @JsonProperty("deletedAccountsLock") final Table deletedAccountsLock, - @JsonProperty("issuedReceipts") final IssuedReceiptsTableConfiguration issuedReceipts, - @JsonProperty("keys") final Table keys, - @JsonProperty("messages") final TableWithExpiration messages, - @JsonProperty("pendingAccounts") final Table pendingAccounts, - @JsonProperty("pendingDevices") final Table pendingDevices, - @JsonProperty("phoneNumberIdentifiers") final Table phoneNumberIdentifiers, - @JsonProperty("profiles") final Table profiles, - @JsonProperty("pushChallenge") final Table pushChallenge, - @JsonProperty("redeemedReceipts") final TableWithExpiration redeemedReceipts, - @JsonProperty("remoteConfig") final Table remoteConfig, - @JsonProperty("reportMessage") final Table reportMessage, - @JsonProperty("reservedUsernames") final Table reservedUsernames, - @JsonProperty("subscriptions") final Table subscriptions, - @JsonProperty("registrationRecovery") final TableWithExpiration registrationRecovery) { - - this.accounts = accounts; - this.deletedAccounts = deletedAccounts; - this.deletedAccountsLock = deletedAccountsLock; - this.issuedReceipts = issuedReceipts; - this.keys = keys; - this.messages = messages; - this.pendingAccounts = pendingAccounts; - this.pendingDevices = pendingDevices; - this.phoneNumberIdentifiers = phoneNumberIdentifiers; - this.profiles = profiles; - this.pushChallenge = pushChallenge; - this.redeemedReceipts = redeemedReceipts; - this.remoteConfig = remoteConfig; - this.reportMessage = reportMessage; - this.reservedUsernames = reservedUsernames; - this.subscriptions = subscriptions; - this.registrationRecovery = registrationRecovery; - } - - @NotNull - @Valid - public AccountsTableConfiguration getAccounts() { - return accounts; - } - - @NotNull - @Valid - public DeletedAccountsTableConfiguration getDeletedAccounts() { - return deletedAccounts; - } - - @NotNull - @Valid - public Table getDeletedAccountsLock() { - return deletedAccountsLock; - } - - @NotNull - @Valid - public IssuedReceiptsTableConfiguration getIssuedReceipts() { - return issuedReceipts; - } - - @NotNull - @Valid - public Table getKeys() { - return keys; - } - - @NotNull - @Valid - public TableWithExpiration getMessages() { - return messages; - } - - @NotNull - @Valid - public Table getPendingAccounts() { - return pendingAccounts; - } - - @NotNull - @Valid - public Table getPendingDevices() { - return pendingDevices; - } - - @NotNull - @Valid - public Table getPhoneNumberIdentifiers() { - return phoneNumberIdentifiers; - } - - @NotNull - @Valid - public Table getProfiles() { - return profiles; - } - - @NotNull - @Valid - public Table getPushChallenge() { - return pushChallenge; - } - - @NotNull - @Valid - public TableWithExpiration getRedeemedReceipts() { - return redeemedReceipts; - } - - @NotNull - @Valid - public Table getRemoteConfig() { - return remoteConfig; - } - - @NotNull - @Valid - public Table getReportMessage() { - return reportMessage; - } - - @NotNull - @Valid - public Table getReservedUsernames() { - return reservedUsernames; - } - - @NotNull - @Valid - public Table getSubscriptions() { - return subscriptions; - } - - @NotNull - @Valid - public TableWithExpiration getRegistrationRecovery() { - return registrationRecovery; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FcmConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FcmConfiguration.java deleted file mode 100644 index 494d23532..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/FcmConfiguration.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import javax.validation.constraints.NotBlank; - -public record FcmConfiguration(@NotBlank String credentials) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GcpAttachmentsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GcpAttachmentsConfiguration.java deleted file mode 100644 index 6970cc96f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GcpAttachmentsConfiguration.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.dropwizard.util.Strings; -import io.dropwizard.validation.ValidationMethod; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotEmpty; - -public class GcpAttachmentsConfiguration { - - @NotEmpty - @JsonProperty - private String domain; - - @NotEmpty - @JsonProperty - private String email; - - @JsonProperty - @Min(1) - private int maxSizeInBytes; - - @JsonProperty - private String pathPrefix; - - @NotEmpty - @JsonProperty - private String rsaSigningKey; - - public String getDomain() { - return domain; - } - - public String getEmail() { - return email; - } - - public int getMaxSizeInBytes() { - return maxSizeInBytes; - } - - public String getPathPrefix() { - return pathPrefix; - } - - public String getRsaSigningKey() { - return rsaSigningKey; - } - - @SuppressWarnings("unused") - @ValidationMethod(message = "pathPrefix must be empty or start with /") - public boolean isPathPrefixValid() { - return Strings.isNullOrEmpty(pathPrefix) || pathPrefix.startsWith("/"); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GraphiteConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GraphiteConfiguration.java deleted file mode 100644 index 8d4ea3947..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/GraphiteConfiguration.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class GraphiteConfiguration { - @JsonProperty - private String host; - - @JsonProperty - private int port; - - public String getHost() { - return host; - } - - public int getPort() { - return port; - } - - public boolean isEnabled() { - return host != null && port != 0; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/HCaptchaConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/HCaptchaConfiguration.java deleted file mode 100644 index 1156b253d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/HCaptchaConfiguration.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import javax.validation.constraints.NotBlank; - -public record HCaptchaConfiguration(@NotBlank String apiKey) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/IssuedReceiptsTableConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/IssuedReceiptsTableConfiguration.java deleted file mode 100644 index e7969f521..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/IssuedReceiptsTableConfiguration.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Duration; -import javax.validation.constraints.NotEmpty; - -public class IssuedReceiptsTableConfiguration extends DynamoDbTables.TableWithExpiration { - - private final byte[] generator; - - public IssuedReceiptsTableConfiguration( - @JsonProperty("tableName") final String tableName, - @JsonProperty("expiration") final Duration expiration, - @JsonProperty("generator") final byte[] generator) { - super(tableName, expiration); - this.generator = generator; - } - - @NotEmpty - public byte[] getGenerator() { - return generator; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/MaxDeviceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/MaxDeviceConfiguration.java deleted file mode 100644 index b9b26ece8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/MaxDeviceConfiguration.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class MaxDeviceConfiguration { - - @JsonProperty - @NotEmpty - private String number; - - @JsonProperty - @NotNull - private int count; - - public String getNumber() { - return number; - } - - public int getCount() { - return count; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/MessageCacheConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/MessageCacheConfiguration.java deleted file mode 100644 index 8f967b334..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/MessageCacheConfiguration.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.Valid; -import javax.validation.constraints.NotNull; - -public class MessageCacheConfiguration { - - @JsonProperty - @NotNull - @Valid - private RedisClusterConfiguration cluster; - - @JsonProperty - private int persistDelayMinutes = 10; - - public RedisClusterConfiguration getRedisClusterConfiguration() { - return cluster; - } - - public int getPersistDelayMinutes() { - return persistDelayMinutes; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java deleted file mode 100644 index c3516fb36..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import java.time.Duration; -import java.util.Map; -import javax.validation.Valid; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.Positive; - -/** - * @param boost configuration for individual donations - * @param gift configuration for gift donations - * @param currencies map of lower-cased ISO 3 currency codes and the suggested donation amounts in that currency - */ -public record OneTimeDonationConfiguration(@Valid ExpiringLevelConfiguration boost, - @Valid ExpiringLevelConfiguration gift, - Map currencies) { - - /** - * @param badge the numeric donation level ID - * @param level the badge ID associated with the level - * @param expiration the duration after which the level expires - */ - public record ExpiringLevelConfiguration(@NotEmpty String badge, @Positive long level, Duration expiration) { - - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java deleted file mode 100644 index 9fa696b3b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import java.math.BigDecimal; -import java.util.List; -import javax.validation.Valid; -import javax.validation.constraints.DecimalMin; -import javax.validation.constraints.NotNull; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -/** - * One-time donation configuration for a given currency - * - * @param minimum the minimum amount permitted to be charged in this currency - * @param gift the suggested gift donation amount - * @param boosts the list of suggested one-time donation amounts - */ -public record OneTimeDonationCurrencyConfiguration( - @NotNull @DecimalMin("0.01") BigDecimal minimum, - @NotNull @DecimalMin("0.01") BigDecimal gift, - @Valid - @ExactlySize(6) - @NotNull - List<@NotNull @DecimalMin("0.01") BigDecimal> boosts) { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/PaymentsServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/PaymentsServiceConfiguration.java deleted file mode 100644 index 474474a0f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/PaymentsServiceConfiguration.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; - -public class PaymentsServiceConfiguration { - - @NotEmpty - @JsonProperty - private String userAuthenticationTokenSharedSecret; - - @NotBlank - @JsonProperty - private String coinMarketCapApiKey; - - @JsonProperty - @NotEmpty - private Map<@NotBlank String, Integer> coinMarketCapCurrencyIds; - - @NotEmpty - @JsonProperty - private String fixerApiKey; - - @NotEmpty - @JsonProperty - private List paymentCurrencies; - - public byte[] getUserAuthenticationTokenSharedSecret() { - return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret); - } - - public String getCoinMarketCapApiKey() { - return coinMarketCapApiKey; - } - - public Map getCoinMarketCapCurrencyIds() { - return coinMarketCapCurrencyIds; - } - - public String getFixerApiKey() { - return fixerApiKey; - } - - public List getPaymentCurrencies() { - return paymentCurrencies; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java deleted file mode 100644 index 1d598d10c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RateLimitsConfiguration.java +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class RateLimitsConfiguration { - - @JsonProperty - private RateLimitConfiguration smsDestination = new RateLimitConfiguration(2, 2); - - @JsonProperty - private RateLimitConfiguration voiceDestination = new RateLimitConfiguration(2, 1.0 / 2.0); - - @JsonProperty - private RateLimitConfiguration voiceDestinationDaily = new RateLimitConfiguration(10, 10.0 / (24.0 * 60.0)); - - @JsonProperty - private RateLimitConfiguration smsVoiceIp = new RateLimitConfiguration(1000, 1000); - - @JsonProperty - private RateLimitConfiguration smsVoicePrefix = new RateLimitConfiguration(1000, 1000); - - @JsonProperty - private RateLimitConfiguration verifyNumber = new RateLimitConfiguration(2, 2); - - @JsonProperty - private RateLimitConfiguration verifyPin = new RateLimitConfiguration(10, 1 / (24.0 * 60.0)); - - @JsonProperty - private RateLimitConfiguration registration = new RateLimitConfiguration(2, 2); - - @JsonProperty - private RateLimitConfiguration attachments = new RateLimitConfiguration(50, 50); - - @JsonProperty - private RateLimitConfiguration prekeys = new RateLimitConfiguration(6, 1.0 / 10.0); - - @JsonProperty - private RateLimitConfiguration messages = new RateLimitConfiguration(60, 60); - - @JsonProperty - private RateLimitConfiguration allocateDevice = new RateLimitConfiguration(2, 1.0 / 2.0); - - @JsonProperty - private RateLimitConfiguration verifyDevice = new RateLimitConfiguration(6, 1.0 / 10.0); - - @JsonProperty - private RateLimitConfiguration turnAllocations = new RateLimitConfiguration(60, 60); - - @JsonProperty - private RateLimitConfiguration profile = new RateLimitConfiguration(4320, 3); - - @JsonProperty - private RateLimitConfiguration stickerPack = new RateLimitConfiguration(50, 20 / (24.0 * 60.0)); - - @JsonProperty - private RateLimitConfiguration artPack = new RateLimitConfiguration(50, 20 / (24.0 * 60.0)); - - @JsonProperty - private RateLimitConfiguration usernameLookup = new RateLimitConfiguration(100, 100 / (24.0 * 60.0)); - - @JsonProperty - private RateLimitConfiguration usernameSet = new RateLimitConfiguration(100, 100 / (24.0 * 60.0)); - - @JsonProperty - private RateLimitConfiguration usernameReserve = new RateLimitConfiguration(100, 100 / (24.0 * 60.0)); - - @JsonProperty - private RateLimitConfiguration checkAccountExistence = new RateLimitConfiguration(1_000, 1_000 / 60.0); - - @JsonProperty - private RateLimitConfiguration backupAuthCheck = new RateLimitConfiguration(100, 100 / (24.0 * 60.0)); - - public RateLimitConfiguration getAllocateDevice() { - return allocateDevice; - } - - public RateLimitConfiguration getVerifyDevice() { - return verifyDevice; - } - - public RateLimitConfiguration getMessages() { - return messages; - } - - public RateLimitConfiguration getPreKeys() { - return prekeys; - } - - public RateLimitConfiguration getAttachments() { - return attachments; - } - - public RateLimitConfiguration getSmsDestination() { - return smsDestination; - } - - public RateLimitConfiguration getVoiceDestination() { - return voiceDestination; - } - - public RateLimitConfiguration getVoiceDestinationDaily() { - return voiceDestinationDaily; - } - - public RateLimitConfiguration getSmsVoiceIp() { - return smsVoiceIp; - } - - public RateLimitConfiguration getSmsVoicePrefix() { - return smsVoicePrefix; - } - - public RateLimitConfiguration getVerifyNumber() { - return verifyNumber; - } - - public RateLimitConfiguration getVerifyPin() { - return verifyPin; - } - - public RateLimitConfiguration getRegistration() { - return registration; - } - - public RateLimitConfiguration getTurnAllocations() { - return turnAllocations; - } - - public RateLimitConfiguration getProfile() { - return profile; - } - - public RateLimitConfiguration getStickerPack() { - return stickerPack; - } - - public RateLimitConfiguration getArtPack() { - return artPack; - } - - public RateLimitConfiguration getUsernameLookup() { - return usernameLookup; - } - - public RateLimitConfiguration getUsernameSet() { - return usernameSet; - } - - public RateLimitConfiguration getUsernameReserve() { - return usernameReserve; - } - - public RateLimitConfiguration getCheckAccountExistence() { - return checkAccountExistence; - } - - public RateLimitConfiguration getBackupAuthCheck() { - return backupAuthCheck; - } - - public static class RateLimitConfiguration { - @JsonProperty - private int bucketSize; - - @JsonProperty - private double leakRatePerMinute; - - public RateLimitConfiguration(int bucketSize, double leakRatePerMinute) { - this.bucketSize = bucketSize; - this.leakRatePerMinute = leakRatePerMinute; - } - - public RateLimitConfiguration() {} - - public int getBucketSize() { - return bucketSize; - } - - public double getLeakRatePerMinute() { - return leakRatePerMinute; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RecaptchaConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RecaptchaConfiguration.java deleted file mode 100644 index 3149be877..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RecaptchaConfiguration.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import javax.validation.constraints.NotEmpty; - -public class RecaptchaConfiguration { - - private String projectPath; - private String credentialConfigurationJson; - - @NotEmpty - public String getProjectPath() { - return projectPath; - } - - @NotEmpty - public String getCredentialConfigurationJson() { - return credentialConfigurationJson; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisClusterConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisClusterConfiguration.java deleted file mode 100644 index 07d1682cf..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisClusterConfiguration.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.Valid; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import java.time.Duration; - -public class RedisClusterConfiguration { - - @JsonProperty - @NotEmpty - private String configurationUri; - - @JsonProperty - @NotNull - private Duration timeout = Duration.ofMillis(3_000); - - @JsonProperty - @NotNull - @Valid - private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration(); - - @JsonProperty - @NotNull - @Valid - private RetryConfiguration retry = new RetryConfiguration(); - - public String getConfigurationUri() { - return configurationUri; - } - - public Duration getTimeout() { - return timeout; - } - - public CircuitBreakerConfiguration getCircuitBreakerConfiguration() { - return circuitBreaker; - } - - public RetryConfiguration getRetryConfiguration() { - return retry; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisConfiguration.java deleted file mode 100644 index affb597eb..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisConfiguration.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Duration; -import java.util.List; -import javax.validation.Valid; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class RedisConfiguration { - - @JsonProperty - @NotEmpty - private String url; - - @JsonProperty - @NotNull - private List replicaUrls; - - @JsonProperty - @NotNull - private Duration timeout = Duration.ofSeconds(10); - - @JsonProperty - @NotNull - @Valid - private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration(); - - public String getUrl() { - return url; - } - - public List getReplicaUrls() { - return replicaUrls; - } - - public Duration getTimeout() { - return timeout; - } - - public CircuitBreakerConfiguration getCircuitBreakerConfiguration() { - return circuitBreaker; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java deleted file mode 100644 index fdd38fb91..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration; - -import javax.validation.constraints.NotBlank; - -public class RegistrationServiceConfiguration { - - @NotBlank - private String host; - - private int port = 443; - - @NotBlank - private String apiKey; - - @NotBlank - private String registrationCaCertificate; - - public String getHost() { - return host; - } - - public void setHost(final String host) { - this.host = host; - } - - public int getPort() { - return port; - } - - public void setPort(final int port) { - this.port = port; - } - - public String getApiKey() { - return apiKey; - } - - public void setApiKey(final String apiKey) { - this.apiKey = apiKey; - } - - public String getRegistrationCaCertificate() { - return registrationCaCertificate; - } - - public void setRegistrationCaCertificate(final String registrationCaCertificate) { - this.registrationCaCertificate = registrationCaCertificate; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RemoteConfigConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RemoteConfigConfiguration.java deleted file mode 100644 index b235d176f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RemoteConfigConfiguration.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.constraints.NotNull; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -public class RemoteConfigConfiguration { - - @JsonProperty - @NotNull - private List authorizedTokens = new LinkedList<>(); - - @NotNull - @JsonProperty - private Map globalConfig = new HashMap<>(); - - public List getAuthorizedTokens() { - return authorizedTokens; - } - - public Map getGlobalConfig() { - return globalConfig; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ReportMessageConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ReportMessageConfiguration.java deleted file mode 100644 index 98d2f0061..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ReportMessageConfiguration.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotNull; -import java.time.Duration; - -public class ReportMessageConfiguration { - - @JsonProperty - @NotNull - private final Duration reportTtl = Duration.ofDays(7); - - @JsonProperty - @NotNull - private final Duration counterTtl = Duration.ofDays(1); - - public Duration getReportTtl() { - return reportTtl; - } - - public Duration getCounterTtl() { - return counterTtl; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RetryConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RetryConfiguration.java deleted file mode 100644 index 9110b05d7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/RetryConfiguration.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.constraints.Min; - -import java.time.Duration; - -import io.github.resilience4j.retry.RetryConfig; - -public class RetryConfiguration { - - @JsonProperty - @Min(1) - private int maxAttempts = 3; - - @JsonProperty - @Min(1) - private long waitDuration = RetryConfig.DEFAULT_WAIT_DURATION; - - public int getMaxAttempts() { - return maxAttempts; - } - - public void setMaxAttempts(final int maxAttempts) { - this.maxAttempts = maxAttempts; - } - - public long getWaitDuration() { - return waitDuration; - } - - public void setWaitDuration(final long waitDuration) { - this.waitDuration = waitDuration; - } - - public RetryConfig toRetryConfig() { - return toRetryConfigBuilder().build(); - } - - public RetryConfig.Builder toRetryConfigBuilder() { - return RetryConfig.custom() - .maxAttempts(getMaxAttempts()) - .waitDuration(Duration.ofMillis(getWaitDuration())); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureBackupServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureBackupServiceConfiguration.java deleted file mode 100644 index 020172c5a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureBackupServiceConfiguration.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import java.util.HexFormat; -import java.util.List; -import javax.validation.Valid; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class SecureBackupServiceConfiguration { - - @NotEmpty - @JsonProperty - private String userAuthenticationTokenSharedSecret; - - @NotBlank - @JsonProperty - private String uri; - - @NotEmpty - @JsonProperty - private List<@NotBlank String> backupCaCertificates; - - @NotNull - @Valid - @JsonProperty - private CircuitBreakerConfiguration circuitBreaker = new CircuitBreakerConfiguration(); - - @NotNull - @Valid - @JsonProperty - private RetryConfiguration retry = new RetryConfiguration(); - - public byte[] getUserAuthenticationTokenSharedSecret() { - return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret); - } - - @VisibleForTesting - public void setUri(final String uri) { - this.uri = uri; - } - - public String getUri() { - return uri; - } - - @VisibleForTesting - public void setBackupCaCertificates(final List backupCaCertificates) { - this.backupCaCertificates = backupCaCertificates; - } - - public List getBackupCaCertificates() { - return backupCaCertificates; - } - - public CircuitBreakerConfiguration getCircuitBreakerConfiguration() { - return circuitBreaker; - } - - public RetryConfiguration getRetryConfiguration() { - return retry; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java deleted file mode 100644 index 4c9bf35aa..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import java.util.HexFormat; -import java.util.List; -import javax.validation.Valid; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; - -public record SecureStorageServiceConfiguration(@NotEmpty String userAuthenticationTokenSharedSecret, - @NotBlank String uri, - @NotEmpty List<@NotBlank String> storageCaCertificates, - @Valid CircuitBreakerConfiguration circuitBreaker, - @Valid RetryConfiguration retry) { - - public SecureStorageServiceConfiguration { - if (circuitBreaker == null) { - circuitBreaker = new CircuitBreakerConfiguration(); - } - if (retry == null) { - retry = new RetryConfiguration(); - } - } - - public byte[] decodeUserAuthenticationTokenSharedSecret() { - return HexFormat.of().parseHex(userAuthenticationTokenSharedSecret); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureValueRecovery2Configuration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureValueRecovery2Configuration.java deleted file mode 100644 index fc46f5918..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureValueRecovery2Configuration.java +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public record SecureValueRecovery2Configuration( - @ExactlySize({32}) byte[] userAuthenticationTokenSharedSecret, - @ExactlySize({32}) byte[] userIdTokenSharedSecret) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SpamFilterConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SpamFilterConfiguration.java deleted file mode 100644 index c30028c35..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SpamFilterConfiguration.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotBlank; - -public class SpamFilterConfiguration { - - @JsonProperty - @NotBlank - private final String environment; - - @JsonCreator - public SpamFilterConfiguration(@JsonProperty("environment") final String environment) { - this.environment = environment; - } - - public String getEnvironment() { - return environment; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SqsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SqsConfiguration.java deleted file mode 100644 index 9db5343c8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SqsConfiguration.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import javax.validation.constraints.NotEmpty; - -public class SqsConfiguration { - @NotEmpty - @JsonProperty - private String accessKey; - - @NotEmpty - @JsonProperty - private String accessSecret; - - @NotEmpty - @JsonProperty - private List queueUrls; - - @NotEmpty - @JsonProperty - private String region = "us-east-1"; - - public String getAccessKey() { - return accessKey; - } - - public String getAccessSecret() { - return accessSecret; - } - - public List getQueueUrls() { - return queueUrls; - } - - public String getRegion() { - return region; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java deleted file mode 100644 index 4ff28adce..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import java.util.Set; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; - -public record StripeConfiguration(@NotBlank String apiKey, - @NotEmpty byte[] idempotencyKeyGenerator, - @NotBlank String boostDescription, - @NotEmpty Set<@NotBlank String> supportedCurrencies) { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java deleted file mode 100644 index a9115ab55..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import io.dropwizard.validation.ValidationMethod; -import java.time.Duration; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import javax.validation.Valid; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotNull; - -public class SubscriptionConfiguration { - - private final Duration badgeGracePeriod; - private final Map levels; - - @JsonCreator - public SubscriptionConfiguration( - @JsonProperty("badgeGracePeriod") @Valid Duration badgeGracePeriod, - @JsonProperty("levels") @Valid Map<@NotNull @Min(1) Long, @NotNull @Valid SubscriptionLevelConfiguration> levels) { - this.badgeGracePeriod = badgeGracePeriod; - this.levels = levels; - } - - public Duration getBadgeGracePeriod() { - return badgeGracePeriod; - } - - public Map getLevels() { - return levels; - } - - @JsonIgnore - @ValidationMethod(message = "has a mismatch between the levels supported currencies") - public boolean isCurrencyListSameAcrossAllLevels() { - Optional any = levels.values().stream().findAny(); - if (any.isEmpty()) { - return true; - } - - Set currencies = any.get().getPrices().keySet(); - return levels.values().stream().allMatch(level -> currencies.equals(level.getPrices().keySet())); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java deleted file mode 100644 index c410295b8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Map; -import javax.validation.Valid; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class SubscriptionLevelConfiguration { - - private final String badge; - private final Map prices; - - @JsonCreator - public SubscriptionLevelConfiguration( - @JsonProperty("badge") @NotEmpty String badge, - @JsonProperty("prices") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices) { - this.badge = badge; - this.prices = prices; - } - - public String getBadge() { - return badge; - } - - public Map getPrices() { - return prices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java deleted file mode 100644 index 21d3d0a2c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import java.math.BigDecimal; -import java.util.Map; -import javax.validation.Valid; -import javax.validation.constraints.DecimalMin; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; - -public record SubscriptionPriceConfiguration(@Valid @NotEmpty Map processorIds, - @NotNull @DecimalMin("0.01") BigDecimal amount) { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TestDeviceConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TestDeviceConfiguration.java deleted file mode 100644 index 82b2fbf35..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TestDeviceConfiguration.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class TestDeviceConfiguration { - - @JsonProperty - @NotEmpty - private String number; - - @JsonProperty - @NotNull - private int code; - - public String getNumber() { - return number; - } - - public int getCode() { - return code; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnUriConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnUriConfiguration.java deleted file mode 100644 index 6bce765a0..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnUriConfiguration.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotNull; -import java.util.Collections; -import java.util.List; -import java.util.Set; - -public class TurnUriConfiguration { - @JsonProperty - @NotNull - private List uris; - - /** - * The weight of this entry for weighted random selection - */ - @JsonProperty - @Min(0) - private long weight = 1; - - /** - * Enrolled numbers will always get this uri list - */ - private Set enrolledNumbers = Collections.emptySet(); - - public List getUris() { - return uris; - } - - public long getWeight() { - return weight; - } - - public Set getEnrolledNumbers() { - return Collections.unmodifiableSet(enrolledNumbers); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/URLDeserializationConverter.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/URLDeserializationConverter.java deleted file mode 100644 index d6f137e50..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/URLDeserializationConverter.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.databind.util.StdConverter; -import java.net.MalformedURLException; -import java.net.URL; - -final class URLDeserializationConverter extends StdConverter { - - @Override - public URL convert(final String value) { - try { - return new URL(value); - } catch (MalformedURLException e) { - throw new IllegalArgumentException(e); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/URLSerializationConverter.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/URLSerializationConverter.java deleted file mode 100644 index a557d6ca5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/URLSerializationConverter.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.databind.util.StdConverter; -import java.net.URL; - -final class URLSerializationConverter extends StdConverter { - - @Override - public String convert(final URL value) { - return value.toString(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/UnidentifiedDeliveryConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/UnidentifiedDeliveryConfiguration.java deleted file mode 100644 index 123d34447..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/UnidentifiedDeliveryConfiguration.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.signal.libsignal.protocol.ecc.Curve; -import org.signal.libsignal.protocol.ecc.ECPrivateKey; -import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; - -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; - -public class UnidentifiedDeliveryConfiguration { - - @JsonProperty - @JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - @NotNull - private byte[] certificate; - - @JsonProperty - @JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - @NotNull - @Size(min = 32, max = 32) - private byte[] privateKey; - - @NotNull - private int expiresDays; - - public byte[] getCertificate() { - return certificate; - } - - public ECPrivateKey getPrivateKey() { - return Curve.decodePrivatePoint(privateKey); - } - - public int getExpiresDays() { - return expiresDays; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ZkConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ZkConfig.java deleted file mode 100644 index 044dcc1b5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/ZkConfig.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; - -import javax.validation.constraints.NotNull; - -public class ZkConfig { - - @JsonProperty - @JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - @NotNull - private byte[] serverSecret; - - @JsonProperty - @JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - @NotNull - private byte[] serverPublic; - - public byte[] getServerSecret() { - return serverSecret; - } - - public byte[] getServerPublic() { - return serverPublic; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java deleted file mode 100644 index fe0751af4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import java.math.BigDecimal; -import java.util.Collections; -import java.util.Set; -import javax.validation.constraints.DecimalMax; -import javax.validation.constraints.DecimalMin; -import javax.validation.constraints.NotNull; - -public class DynamicCaptchaConfiguration { - - @JsonProperty - @DecimalMin("0") - @DecimalMax("1") - @NotNull - private BigDecimal scoreFloor; - - @JsonProperty - private boolean allowHCaptcha = false; - - @JsonProperty - private boolean allowRecaptcha = true; - - @JsonProperty - @NotNull - private Set signupCountryCodes = Collections.emptySet(); - - @JsonProperty - @NotNull - private Set signupRegions = Collections.emptySet(); - - public BigDecimal getScoreFloor() { - return scoreFloor; - } - - public Set getSignupCountryCodes() { - return signupCountryCodes; - } - - @VisibleForTesting - public void setSignupCountryCodes(Set numbers) { - this.signupCountryCodes = numbers; - } - - @VisibleForTesting - public void setSignupRegions(final Set signupRegions) { - this.signupRegions = signupRegions; - } - - public Set getSignupRegions() { - return signupRegions; - } - - public boolean isAllowHCaptcha() { - return allowHCaptcha; - } - - public boolean isAllowRecaptcha() { - return allowRecaptcha; - } - - @VisibleForTesting - public void setAllowHCaptcha(final boolean allowHCaptcha) { - this.allowHCaptcha = allowHCaptcha; - } - - @VisibleForTesting - public void setScoreFloor(final BigDecimal scoreFloor) { - this.scoreFloor = scoreFloor; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java deleted file mode 100644 index 21f364e71..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java +++ /dev/null @@ -1,107 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import javax.validation.Valid; - -public class DynamicConfiguration { - - @JsonProperty - @Valid - private Map experiments = Collections.emptyMap(); - - @JsonProperty - @Valid - private Map preRegistrationExperiments = Collections.emptyMap(); - - @JsonProperty - @Valid - private DynamicRateLimitsConfiguration limits = new DynamicRateLimitsConfiguration(); - - @JsonProperty - @Valid - private DynamicRemoteDeprecationConfiguration remoteDeprecation = new DynamicRemoteDeprecationConfiguration(); - - @JsonProperty - @Valid - private DynamicPaymentsConfiguration payments = new DynamicPaymentsConfiguration(); - - @JsonProperty - @Valid - private DynamicCaptchaConfiguration captcha = new DynamicCaptchaConfiguration(); - - @JsonProperty - @Valid - private DynamicRateLimitChallengeConfiguration rateLimitChallenge = new DynamicRateLimitChallengeConfiguration(); - - @JsonProperty - private DynamicDirectoryReconcilerConfiguration directoryReconciler = new DynamicDirectoryReconcilerConfiguration(); - - @JsonProperty - @Valid - private DynamicPushLatencyConfiguration pushLatency = new DynamicPushLatencyConfiguration(Collections.emptyMap()); - - @JsonProperty - @Valid - private DynamicTurnConfiguration turn = new DynamicTurnConfiguration(); - - @JsonProperty - @Valid - DynamicMessagePersisterConfiguration messagePersister = new DynamicMessagePersisterConfiguration(); - - @JsonProperty - @Valid - DynamicPushNotificationConfiguration pushNotifications = new DynamicPushNotificationConfiguration(); - - public Optional getExperimentEnrollmentConfiguration( - final String experimentName) { - return Optional.ofNullable(experiments.get(experimentName)); - } - - public Optional getPreRegistrationEnrollmentConfiguration( - final String experimentName) { - return Optional.ofNullable(preRegistrationExperiments.get(experimentName)); - } - - public DynamicRateLimitsConfiguration getLimits() { - return limits; - } - - public DynamicRemoteDeprecationConfiguration getRemoteDeprecationConfiguration() { - return remoteDeprecation; - } - - public DynamicPaymentsConfiguration getPaymentsConfiguration() { - return payments; - } - - public DynamicCaptchaConfiguration getCaptchaConfiguration() { - return captcha; - } - - public DynamicRateLimitChallengeConfiguration getRateLimitChallengeConfiguration() { - return rateLimitChallenge; - } - - public DynamicDirectoryReconcilerConfiguration getDirectoryReconcilerConfiguration() { - return directoryReconciler; - } - - public DynamicPushLatencyConfiguration getPushLatencyConfiguration() { - return pushLatency; - } - - public DynamicTurnConfiguration getTurnConfiguration() { - return turn; - } - - public DynamicMessagePersisterConfiguration getMessagePersisterConfiguration() { - return messagePersister; - } - - public DynamicPushNotificationConfiguration getPushNotificationConfiguration() { - return pushNotifications; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicDirectoryReconcilerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicDirectoryReconcilerConfiguration.java deleted file mode 100644 index bcd4716ea..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicDirectoryReconcilerConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class DynamicDirectoryReconcilerConfiguration { - - @JsonProperty - private boolean enabled = true; - - public boolean isEnabled() { - return enabled; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java deleted file mode 100644 index bd4b0b699..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.Valid; -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import java.util.Collections; -import java.util.Set; -import java.util.UUID; - -public class DynamicExperimentEnrollmentConfiguration { - - @JsonProperty - @Valid - private Set enrolledUuids = Collections.emptySet(); - - @JsonProperty - @Valid - @Min(0) - @Max(100) - private int enrollmentPercentage = 0; - - public Set getEnrolledUuids() { - return enrolledUuids; - } - - public int getEnrollmentPercentage() { - return enrollmentPercentage; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicMessagePersisterConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicMessagePersisterConfiguration.java deleted file mode 100644 index d74cac20d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicMessagePersisterConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class DynamicMessagePersisterConfiguration { - - @JsonProperty - private boolean persistenceEnabled = true; - - public boolean isPersistenceEnabled() { - return persistenceEnabled; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPaymentsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPaymentsConfiguration.java deleted file mode 100644 index 01923b182..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPaymentsConfiguration.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Collections; -import java.util.List; - -public class DynamicPaymentsConfiguration { - - @JsonProperty - private List disallowedPrefixes = Collections.emptyList(); - - public List getDisallowedPrefixes() { - return disallowedPrefixes; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPreRegistrationExperimentEnrollmentConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPreRegistrationExperimentEnrollmentConfiguration.java deleted file mode 100644 index d81b1595e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPreRegistrationExperimentEnrollmentConfiguration.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Collections; -import java.util.Set; -import javax.validation.Valid; -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; - -public class DynamicPreRegistrationExperimentEnrollmentConfiguration { - - @JsonProperty - @Valid - private Set enrolledE164s = Collections.emptySet(); - - @JsonProperty - @Valid - private Set excludedE164s = Collections.emptySet(); - - @JsonProperty - @Valid - private Set includedCountryCodes = Collections.emptySet(); - - @JsonProperty - @Valid - private Set excludedCountryCodes = Collections.emptySet(); - - @JsonProperty - @Valid - @Min(0) - @Max(100) - private int enrollmentPercentage = 0; - - public Set getEnrolledE164s() { - return enrolledE164s; - } - - public Set getExcludedE164s() { - return excludedE164s; - } - - public Set getIncludedCountryCodes() { - return includedCountryCodes; - } - - public Set getExcludedCountryCodes() { - return excludedCountryCodes; - } - - public int getEnrollmentPercentage() { - return enrollmentPercentage; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPushLatencyConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPushLatencyConfiguration.java deleted file mode 100644 index beed2829a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPushLatencyConfiguration.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.vdurmont.semver4j.Semver; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; -import java.util.Map; -import java.util.Set; - -public class DynamicPushLatencyConfiguration { - - private final Map> instrumentedVersions; - - @JsonCreator - public DynamicPushLatencyConfiguration(@JsonProperty("instrumentedVersions") final Map> instrumentedVersions) { - this.instrumentedVersions = instrumentedVersions; - } - - public Map> getInstrumentedVersions() { - return instrumentedVersions; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPushNotificationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPushNotificationConfiguration.java deleted file mode 100644 index f442d25c0..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPushNotificationConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class DynamicPushNotificationConfiguration { - - @JsonProperty - private boolean lowUrgencyEnabled = false; - - public boolean isLowUrgencyEnabled() { - return lowUrgencyEnabled; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRateLimitChallengeConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRateLimitChallengeConfiguration.java deleted file mode 100644 index ebaa0f23c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRateLimitChallengeConfiguration.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import com.vdurmont.semver4j.Semver; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; -import javax.validation.constraints.NotNull; - -public class DynamicRateLimitChallengeConfiguration { - - @JsonProperty - @NotNull - private Map clientSupportedVersions = Collections.emptyMap(); - - @VisibleForTesting - Map getClientSupportedVersions() { - return clientSupportedVersions; - } - - public Optional getMinimumSupportedVersion(final ClientPlatform platform) { - return Optional.ofNullable(clientSupportedVersions.get(platform)); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRateLimitsConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRateLimitsConfiguration.java deleted file mode 100644 index e304209cd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRateLimitsConfiguration.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration; - -public class DynamicRateLimitsConfiguration { - - @JsonProperty - private RateLimitConfiguration rateLimitReset = new RateLimitConfiguration(2, 2.0 / (60 * 24)); - - @JsonProperty - private RateLimitConfiguration recaptchaChallengeAttempt = new RateLimitConfiguration(10, 10.0 / (60 * 24)); - - @JsonProperty - private RateLimitConfiguration recaptchaChallengeSuccess = new RateLimitConfiguration(2, 2.0 / (60 * 24)); - - @JsonProperty - private RateLimitConfiguration pushChallengeAttempt = new RateLimitConfiguration(10, 10.0 / (60 * 24)); - - @JsonProperty - private RateLimitConfiguration pushChallengeSuccess = new RateLimitConfiguration(2, 2.0 / (60 * 24)); - - public RateLimitConfiguration getRateLimitReset() { - return rateLimitReset; - } - - public RateLimitConfiguration getRecaptchaChallengeAttempt() { - return recaptchaChallengeAttempt; - } - - public RateLimitConfiguration getRecaptchaChallengeSuccess() { - return recaptchaChallengeSuccess; - } - - public RateLimitConfiguration getPushChallengeAttempt() { - return pushChallengeAttempt; - } - - public RateLimitConfiguration getPushChallengeSuccess() { - return pushChallengeSuccess; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java deleted file mode 100644 index 428532503..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import com.vdurmont.semver4j.Semver; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; - -import java.util.Collections; -import java.util.Map; -import java.util.Set; - -public class DynamicRemoteDeprecationConfiguration { - - @JsonProperty - private Map minimumVersions = Collections.emptyMap(); - - @JsonProperty - private Map versionsPendingDeprecation = Collections.emptyMap(); - - @JsonProperty - private Map> blockedVersions = Collections.emptyMap(); - - @JsonProperty - private Map> versionsPendingBlock = Collections.emptyMap(); - - @JsonProperty - private boolean unrecognizedUserAgentAllowed = true; - - @VisibleForTesting - public void setMinimumVersions(final Map minimumVersions) { - this.minimumVersions = minimumVersions; - } - - public Map getMinimumVersions() { - return minimumVersions; - } - - @VisibleForTesting - public void setVersionsPendingDeprecation(final Map versionsPendingDeprecation) { - this.versionsPendingDeprecation = versionsPendingDeprecation; - } - - public Map getVersionsPendingDeprecation() { - return versionsPendingDeprecation; - } - - @VisibleForTesting - public void setUnrecognizedUserAgentAllowed(final boolean allowUnrecognizedUserAgents) { - this.unrecognizedUserAgentAllowed = allowUnrecognizedUserAgents; - } - - public boolean isUnrecognizedUserAgentAllowed() { - return unrecognizedUserAgentAllowed; - } - - @VisibleForTesting - public void setBlockedVersions(final Map> blockedVersions) { - this.blockedVersions = blockedVersions; - } - - public Map> getBlockedVersions() { - return blockedVersions; - } - - @VisibleForTesting - public void setVersionsPendingBlock(final Map> versionsPendingBlock) { - this.versionsPendingBlock = versionsPendingBlock; - } - - public Map> getVersionsPendingBlock() { - return versionsPendingBlock; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicTurnConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicTurnConfiguration.java deleted file mode 100644 index 524898eb5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicTurnConfiguration.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Collections; -import java.util.List; -import javax.validation.Valid; -import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration; - -public class DynamicTurnConfiguration { - - @JsonProperty - private String secret; - - @JsonProperty - private List<@Valid TurnUriConfiguration> uriConfigs = Collections.emptyList(); - - public List getUriConfigs() { - return uriConfigs; - } - - public String getSecret() { - return secret; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java deleted file mode 100644 index 8a2da844a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java +++ /dev/null @@ -1,967 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.annotation.Timed; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import com.google.i18n.phonenumbers.Phonenumber; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.DistributionSummary; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import java.io.IOException; -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HexFormat; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletionException; -import javax.servlet.http.HttpServletRequest; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.DefaultValue; -import javax.ws.rs.ForbiddenException; -import javax.ws.rs.GET; -import javax.ws.rs.HEAD; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import org.apache.commons.lang3.StringUtils; -import org.signal.libsignal.usernames.BaseUsernameException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader; -import org.whispersystems.textsecuregcm.auth.ChangesDeviceEnabledState; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; -import org.whispersystems.textsecuregcm.auth.TurnToken; -import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; -import org.whispersystems.textsecuregcm.captcha.AssessmentResult; -import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse; -import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; -import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; -import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest; -import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest; -import org.whispersystems.textsecuregcm.entities.DeviceName; -import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; -import org.whispersystems.textsecuregcm.entities.MismatchedDevices; -import org.whispersystems.textsecuregcm.entities.RegistrationLock; -import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest; -import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse; -import org.whispersystems.textsecuregcm.entities.StaleDevices; -import org.whispersystems.textsecuregcm.entities.UsernameHashResponse; -import org.whispersystems.textsecuregcm.limits.RateLimitedByIp; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.push.PushNotification; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.registration.ClientType; -import org.whispersystems.textsecuregcm.registration.MessageTransport; -import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; -import org.whispersystems.textsecuregcm.spam.FilterSpam; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; -import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.HeaderUtils; -import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException; -import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException; -import org.whispersystems.textsecuregcm.util.Optionals; -import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier; -import org.whispersystems.textsecuregcm.util.Util; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@Path("/v1/accounts") -public class AccountController { - public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20; - public static final int USERNAME_HASH_LENGTH = 32; - private final Logger logger = LoggerFactory.getLogger(AccountController.class); - private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private final Meter countryFilteredHostMeter = metricRegistry.meter(name(AccountController.class, "country_limited_host" )); - private final Meter rateLimitedHostMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_host" )); - private final Meter rateLimitedPrefixMeter = metricRegistry.meter(name(AccountController.class, "rate_limited_prefix" )); - private final Meter captchaRequiredMeter = metricRegistry.meter(name(AccountController.class, "captcha_required" )); - - private static final String PUSH_CHALLENGE_COUNTER_NAME = name(AccountController.class, "pushChallenge"); - private static final String ACCOUNT_CREATE_COUNTER_NAME = name(AccountController.class, "create"); - private static final String ACCOUNT_VERIFY_COUNTER_NAME = name(AccountController.class, "verify"); - private static final String CAPTCHA_ATTEMPT_COUNTER_NAME = name(AccountController.class, "captcha"); - private static final String CHALLENGE_ISSUED_COUNTER_NAME = name(AccountController.class, "challengeIssued"); - - private static final DistributionSummary REREGISTRATION_IDLE_DAYS_DISTRIBUTION = DistributionSummary - .builder(name(AccountController.class, "reregistrationIdleDays")) - .publishPercentiles(0.75, 0.95, 0.99, 0.999) - .distributionStatisticExpiry(Duration.ofHours(2)) - .register(Metrics.globalRegistry); - - private static final String CHALLENGE_PRESENT_TAG_NAME = "present"; - private static final String CHALLENGE_MATCH_TAG_NAME = "matches"; - private static final String COUNTRY_CODE_TAG_NAME = "countryCode"; - - /** - * @deprecated "region" conflicts with cloud provider region tags; prefer "regionCode" instead - */ - @Deprecated - private static final String REGION_TAG_NAME = "region"; - private static final String REGION_CODE_TAG_NAME = "regionCode"; - private static final String VERIFICATION_TRANSPORT_TAG_NAME = "transport"; - private static final String SCORE_TAG_NAME = "score"; - - - private final StoredVerificationCodeManager pendingAccounts; - private final AccountsManager accounts; - private final RateLimiters rateLimiters; - private final RegistrationServiceClient registrationServiceClient; - private final DynamicConfigurationManager dynamicConfigurationManager; - private final TurnTokenGenerator turnTokenGenerator; - private final Map testDevices; - private final CaptchaChecker captchaChecker; - private final PushNotificationManager pushNotificationManager; - private final RegistrationLockVerificationManager registrationLockVerificationManager; - private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; - private final ChangeNumberManager changeNumberManager; - private final Clock clock; - private final UsernameHashZkProofVerifier usernameHashZkProofVerifier; - - - @VisibleForTesting - static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15); - - public AccountController( - StoredVerificationCodeManager pendingAccounts, - AccountsManager accounts, - RateLimiters rateLimiters, - RegistrationServiceClient registrationServiceClient, - DynamicConfigurationManager dynamicConfigurationManager, - TurnTokenGenerator turnTokenGenerator, - Map testDevices, - CaptchaChecker captchaChecker, - PushNotificationManager pushNotificationManager, - ChangeNumberManager changeNumberManager, - RegistrationLockVerificationManager registrationLockVerificationManager, - RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager, - UsernameHashZkProofVerifier usernameHashZkProofVerifier, - Clock clock - ) { - this.pendingAccounts = pendingAccounts; - this.accounts = accounts; - this.rateLimiters = rateLimiters; - this.registrationServiceClient = registrationServiceClient; - this.dynamicConfigurationManager = dynamicConfigurationManager; - this.testDevices = testDevices; - this.turnTokenGenerator = turnTokenGenerator; - this.captchaChecker = captchaChecker; - this.pushNotificationManager = pushNotificationManager; - this.registrationLockVerificationManager = registrationLockVerificationManager; - this.changeNumberManager = changeNumberManager; - this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager; - this.usernameHashZkProofVerifier = usernameHashZkProofVerifier; - this.clock = clock; - } - - @Timed - @GET - @Path("/{type}/preauth/{token}/{number}") - @Produces(MediaType.APPLICATION_JSON) - public Response getPreAuth(@PathParam("type") String pushType, - @PathParam("token") String pushToken, - @PathParam("number") String number, - @QueryParam("voip") @DefaultValue("true") boolean useVoip) - throws ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, RateLimitExceededException { - - final PushNotification.TokenType tokenType = switch(pushType) { - case "apn" -> useVoip ? PushNotification.TokenType.APN_VOIP : PushNotification.TokenType.APN; - case "fcm" -> PushNotification.TokenType.FCM; - default -> throw new BadRequestException(); - }; - - Util.requireNormalizedNumber(number); - - final Phonenumber.PhoneNumber phoneNumber; - try { - phoneNumber = PhoneNumberUtil.getInstance().parse(number, null); - } catch (final NumberParseException e) { - // This should never happen since we just verified that the number is already normalized - throw new BadRequestException("Bad phone number"); - } - - final StoredVerificationCode storedVerificationCode; - { - final Optional maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number); - - if (maybeStoredVerificationCode.isPresent()) { - final StoredVerificationCode existingStoredVerificationCode = maybeStoredVerificationCode.get(); - - if (StringUtils.isBlank(existingStoredVerificationCode.pushCode())) { - storedVerificationCode = new StoredVerificationCode( - existingStoredVerificationCode.code(), - existingStoredVerificationCode.timestamp(), - generatePushChallenge(), - existingStoredVerificationCode.sessionId()); - } else { - storedVerificationCode = existingStoredVerificationCode; - } - } else { - final byte[] sessionId = createRegistrationSession(phoneNumber); - storedVerificationCode = new StoredVerificationCode(null, clock.millis(), generatePushChallenge(), sessionId); - } - } - - pendingAccounts.store(number, storedVerificationCode); - pushNotificationManager.sendRegistrationChallengeNotification(pushToken, tokenType, storedVerificationCode.pushCode()); - - return Response.ok().build(); - } - - @Timed - @GET - @Path("/{transport}/code/{number}") - @FilterSpam - @Produces(MediaType.APPLICATION_JSON) - public Response createAccount(@PathParam("transport") String transport, - @PathParam("number") String number, - @HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, - @HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) Optional acceptLanguage, - @QueryParam("client") Optional client, - @QueryParam("captcha") Optional captcha, - @QueryParam("challenge") Optional pushChallenge) - throws RateLimitExceededException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException, IOException { - - Util.requireNormalizedNumber(number); - - final String sourceHost = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow(); - final Optional maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number); - - final String countryCode = Util.getCountryCode(number); - final String region = Util.getRegion(number); - - // if there's a captcha, assess it, otherwise check if we need a captcha - final Optional assessmentResult = captcha.isPresent() - ? Optional.of(captchaChecker.verify(captcha.get(), sourceHost)) - : Optional.empty(); - - assessmentResult.ifPresent(result -> - Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of( - Tag.of("success", String.valueOf(result.valid())), - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(COUNTRY_CODE_TAG_NAME, countryCode), - Tag.of(REGION_TAG_NAME, region), - Tag.of(REGION_CODE_TAG_NAME, region), - Tag.of(SCORE_TAG_NAME, result.score()))) - .increment()); - - final boolean pushChallengeMatch = pushChallengeMatches(number, pushChallenge, maybeStoredVerificationCode); - - if (pushChallenge.isPresent() && !pushChallengeMatch) { - throw new WebApplicationException(Response.status(403).build()); - } - - final boolean requiresCaptcha = assessmentResult - .map(result -> !result.valid()) - .orElseGet(() -> requiresCaptcha(number, transport, forwardedFor, sourceHost, pushChallengeMatch)); - - if (requiresCaptcha) { - captchaRequiredMeter.mark(); - Metrics.counter(CHALLENGE_ISSUED_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)), - Tag.of(REGION_TAG_NAME, Util.getRegion(number)), - Tag.of(REGION_CODE_TAG_NAME, region))) - .increment(); - return Response.status(402).build(); - } - - switch (transport) { - case "sms" -> rateLimiters.getSmsDestinationLimiter().validate(number); - case "voice" -> { - rateLimiters.getVoiceDestinationLimiter().validate(number); - rateLimiters.getVoiceDestinationDailyLimiter().validate(number); - } - default -> throw new WebApplicationException(Response.status(422).build()); - } - - final Phonenumber.PhoneNumber phoneNumber; - - try { - phoneNumber = PhoneNumberUtil.getInstance().parse(number, null); - } catch (final NumberParseException e) { - throw new WebApplicationException(Response.status(422).build()); - } - - final MessageTransport messageTransport = switch (transport) { - case "sms" -> MessageTransport.SMS; - case "voice" -> MessageTransport.VOICE; - default -> throw new WebApplicationException(Response.status(422).build()); - }; - - final ClientType clientType = client.map(clientTypeString -> { - if ("ios".equalsIgnoreCase(clientTypeString)) { - return ClientType.IOS; - } else if ("android-2021-03".equalsIgnoreCase(clientTypeString)) { - return ClientType.ANDROID_WITH_FCM; - } else if (StringUtils.startsWithIgnoreCase(clientTypeString, "android")) { - return ClientType.ANDROID_WITHOUT_FCM; - } else { - return ClientType.UNKNOWN; - } - }).orElse(ClientType.UNKNOWN); - - // During the transition to explicit session creation, some previously-stored records may not have a session ID; - // after the transition, we can assume that any existing record has an associated session ID. - final byte[] sessionId = maybeStoredVerificationCode.isPresent() && maybeStoredVerificationCode.get().sessionId() != null ? - maybeStoredVerificationCode.get().sessionId() : createRegistrationSession(phoneNumber); - - sendVerificationCode(sessionId, messageTransport, clientType, acceptLanguage); - - final StoredVerificationCode storedVerificationCode = new StoredVerificationCode(null, - clock.millis(), - maybeStoredVerificationCode.map(StoredVerificationCode::pushCode).orElse(null), - sessionId); - - pendingAccounts.store(number, storedVerificationCode); - - Metrics.counter(ACCOUNT_CREATE_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)), - Tag.of(REGION_TAG_NAME, Util.getRegion(number)), - Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, transport))) - .increment(); - - return Response.ok().build(); - } - - @Timed - @PUT - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @Path("/code/{verification_code}") - public AccountIdentityResponse verifyAccount(@PathParam("verification_code") String verificationCode, - @HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader, - @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String signalAgent, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, - @QueryParam("transfer") Optional availableForTransfer, - @NotNull @Valid AccountAttributes accountAttributes) - throws RateLimitExceededException, InterruptedException { - - String number = authorizationHeader.getUsername(); - String password = authorizationHeader.getPassword(); - - rateLimiters.getVerifyLimiter().validate(number); - - // Note that successful verification depends on being able to find a stored verification code for the given number. - // We check that numbers are normalized before we store verification codes, and so don't need to re-assert - // normalization here. - final boolean codeVerified; - final Optional maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number); - - if (maybeStoredVerificationCode.isPresent()) { - codeVerified = checkVerificationCode(maybeStoredVerificationCode.get().sessionId(), verificationCode); - } else { - codeVerified = false; - } - - if (!codeVerified) { - throw new WebApplicationException(Response.status(403).build()); - } - - Optional existingAccount = accounts.getByE164(number); - - existingAccount.ifPresent(account -> { - Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen()); - Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now()); - REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays()); - }); - - if (existingAccount.isPresent()) { - registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), - accountAttributes.getRegistrationLock()); - } - - if (availableForTransfer.orElse(false) && existingAccount.map(Account::isTransferSupported).orElse(false)) { - throw new WebApplicationException(Response.status(409).build()); - } - - rateLimiters.getVerifyLimiter().clear(number); - - Account account = accounts.create(number, password, signalAgent, accountAttributes, - existingAccount.map(Account::getBadges).orElseGet(ArrayList::new)); - - metricRegistry.meter(name(AccountController.class, "verify", Util.getCountryCode(number))).mark(); - - Metrics.counter(ACCOUNT_VERIFY_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)), - Tag.of(REGION_TAG_NAME, Util.getRegion(number)), - Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)))) - .increment(); - - return new AccountIdentityResponse(account.getUuid(), - account.getNumber(), - account.getPhoneNumberIdentifier(), - account.getUsernameHash().orElse(null), - existingAccount.map(Account::isStorageSupported).orElse(false)); - } - - @Timed - @PUT - @Path("/number") - @Produces(MediaType.APPLICATION_JSON) - public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount, - @NotNull @Valid final ChangePhoneNumberRequest request, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) - throws RateLimitExceededException, InterruptedException, ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException { - - if (!authenticatedAccount.getAuthenticatedDevice().isMaster()) { - throw new ForbiddenException(); - } - - final String number = request.number(); - - // Only "bill" for rate limiting if we think there's a change to be made... - if (!authenticatedAccount.getAccount().getNumber().equals(number)) { - Util.requireNormalizedNumber(number); - - rateLimiters.getVerifyLimiter().validate(number); - - final boolean codeVerified; - final Optional maybeStoredVerificationCode = pendingAccounts.getCodeForNumber(number); - - if (maybeStoredVerificationCode.isPresent()) { - codeVerified = checkVerificationCode(maybeStoredVerificationCode.get().sessionId(), request.code()); - } else { - codeVerified = false; - } - - if (!codeVerified) { - throw new ForbiddenException(); - } - - final Optional existingAccount = accounts.getByE164(number); - - if (existingAccount.isPresent()) { - registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock()); - } - - rateLimiters.getVerifyLimiter().clear(number); - } - - // ...but always attempt to make the change in case a client retries and needs to re-send messages - try { - final Account updatedAccount = changeNumberManager.changeNumber( - authenticatedAccount.getAccount(), - request.number(), - request.pniIdentityKey(), - request.devicePniSignedPrekeys(), - request.deviceMessages(), - request.pniRegistrationIds()); - - return new AccountIdentityResponse( - updatedAccount.getUuid(), - updatedAccount.getNumber(), - updatedAccount.getPhoneNumberIdentifier(), - updatedAccount.getUsernameHash().orElse(null), - updatedAccount.isStorageSupported()); - } catch (MismatchedDevicesException e) { - throw new WebApplicationException(Response.status(409) - .type(MediaType.APPLICATION_JSON_TYPE) - .entity(new MismatchedDevices(e.getMissingDevices(), - e.getExtraDevices())) - .build()); - } catch (StaleDevicesException e) { - throw new WebApplicationException(Response.status(410) - .type(MediaType.APPLICATION_JSON) - .entity(new StaleDevices(e.getStaleDevices())) - .build()); - } catch (IllegalArgumentException e) { - throw new BadRequestException(e); - } - } - - @Timed - @GET - @Path("/turn/") - @Produces(MediaType.APPLICATION_JSON) - public TurnToken getTurnToken(@Auth AuthenticatedAccount auth) throws RateLimitExceededException { - rateLimiters.getTurnLimiter().validate(auth.getAccount().getUuid()); - return turnTokenGenerator.generate(auth.getAccount().getNumber()); - } - - @Timed - @PUT - @Path("/gcm/") - @Consumes(MediaType.APPLICATION_JSON) - @ChangesDeviceEnabledState - public void setGcmRegistrationId(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth, - @NotNull @Valid GcmRegistrationId registrationId) { - Account account = disabledPermittedAuth.getAccount(); - Device device = disabledPermittedAuth.getAuthenticatedDevice(); - - if (device.getGcmId() != null && - device.getGcmId().equals(registrationId.getGcmRegistrationId())) { - return; - } - - accounts.updateDevice(account, device.getId(), d -> { - d.setApnId(null); - d.setVoipApnId(null); - d.setGcmId(registrationId.getGcmRegistrationId()); - d.setFetchesMessages(false); - }); - } - - @Timed - @DELETE - @Path("/gcm/") - @ChangesDeviceEnabledState - public void deleteGcmRegistrationId(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth) { - Account account = disabledPermittedAuth.getAccount(); - Device device = disabledPermittedAuth.getAuthenticatedDevice(); - - accounts.updateDevice(account, device.getId(), d -> { - d.setGcmId(null); - d.setFetchesMessages(false); - d.setUserAgent("OWA"); - }); - } - - @Timed - @PUT - @Path("/apn/") - @Consumes(MediaType.APPLICATION_JSON) - @ChangesDeviceEnabledState - public void setApnRegistrationId(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth, - @NotNull @Valid ApnRegistrationId registrationId) { - Account account = disabledPermittedAuth.getAccount(); - Device device = disabledPermittedAuth.getAuthenticatedDevice(); - - accounts.updateDevice(account, device.getId(), d -> { - d.setApnId(registrationId.getApnRegistrationId()); - d.setVoipApnId(registrationId.getVoipRegistrationId()); - d.setGcmId(null); - d.setFetchesMessages(false); - }); - } - - @Timed - @DELETE - @Path("/apn/") - @ChangesDeviceEnabledState - public void deleteApnRegistrationId(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth) { - Account account = disabledPermittedAuth.getAccount(); - Device device = disabledPermittedAuth.getAuthenticatedDevice(); - - accounts.updateDevice(account, device.getId(), d -> { - d.setApnId(null); - d.setFetchesMessages(false); - if (d.getId() == 1) { - d.setUserAgent("OWI"); - } else { - d.setUserAgent("OWP"); - } - }); - } - - @Timed - @PUT - @Produces(MediaType.APPLICATION_JSON) - @Path("/registration_lock") - public void setRegistrationLock(@Auth AuthenticatedAccount auth, @NotNull @Valid RegistrationLock accountLock) { - SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.getRegistrationLock()); - - accounts.update(auth.getAccount(), - a -> a.setRegistrationLock(credentials.hash(), credentials.salt())); - } - - @Timed - @DELETE - @Path("/registration_lock") - public void removeRegistrationLock(@Auth AuthenticatedAccount auth) { - accounts.update(auth.getAccount(), a -> a.setRegistrationLock(null, null)); - } - - @Timed - @PUT - @Path("/name/") - public void setName(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth, @NotNull @Valid DeviceName deviceName) { - Account account = disabledPermittedAuth.getAccount(); - Device device = disabledPermittedAuth.getAuthenticatedDevice(); - accounts.updateDevice(account, device.getId(), d -> d.setName(deviceName.getDeviceName())); - } - - @Timed - @DELETE - @Path("/signaling_key") - public void removeSignalingKey(@Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth) { - } - - @Timed - @PUT - @Path("/attributes/") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @ChangesDeviceEnabledState - public void setAccountAttributes( - @Auth DisabledPermittedAuthenticatedAccount disabledPermittedAuth, - @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent, - @NotNull @Valid AccountAttributes attributes) { - final Account account = disabledPermittedAuth.getAccount(); - final long deviceId = disabledPermittedAuth.getAuthenticatedDevice().getId(); - - final Account updatedAccount = accounts.update(account, a -> { - a.getDevice(deviceId).ifPresent(d -> { - d.setFetchesMessages(attributes.getFetchesMessages()); - d.setName(attributes.getName()); - d.setLastSeen(Util.todayInMillis()); - d.setCapabilities(attributes.getCapabilities()); - d.setRegistrationId(attributes.getRegistrationId()); - attributes.getPhoneNumberIdentityRegistrationId().ifPresent(d::setPhoneNumberIdentityRegistrationId); - d.setUserAgent(userAgent); - }); - - a.setRegistrationLockFromAttributes(attributes); - a.setUnidentifiedAccessKey(attributes.getUnidentifiedAccessKey()); - a.setUnrestrictedUnidentifiedAccess(attributes.isUnrestrictedUnidentifiedAccess()); - a.setDiscoverableByPhoneNumber(attributes.isDiscoverableByPhoneNumber()); - }); - - // if registration recovery password was sent to us, store it (or refresh its expiration) - attributes.recoveryPassword().ifPresent(registrationRecoveryPassword -> - registrationRecoveryPasswordsManager.storeForCurrentNumber(updatedAccount.getNumber(), registrationRecoveryPassword)); - } - - @GET - @Path("/me") - @Produces(MediaType.APPLICATION_JSON) - public AccountIdentityResponse getMe(@Auth AuthenticatedAccount auth) { - return whoAmI(auth); - } - - @GET - @Path("/whoami") - @Produces(MediaType.APPLICATION_JSON) - public AccountIdentityResponse whoAmI(@Auth AuthenticatedAccount auth) { - return new AccountIdentityResponse(auth.getAccount().getUuid(), - auth.getAccount().getNumber(), - auth.getAccount().getPhoneNumberIdentifier(), - auth.getAccount().getUsernameHash().orElse(null), - auth.getAccount().isStorageSupported()); - } - - @Timed - @DELETE - @Path("/username_hash") - @Produces(MediaType.APPLICATION_JSON) - public void deleteUsernameHash(@Auth AuthenticatedAccount auth) { - accounts.clearUsernameHash(auth.getAccount()); - } - - @Timed - @PUT - @Path("/username_hash/reserve") - @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) - public ReserveUsernameHashResponse reserveUsernameHash(@Auth AuthenticatedAccount auth, - @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent, - @NotNull @Valid ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException { - - rateLimiters.getUsernameReserveLimiter().validate(auth.getAccount().getUuid()); - - for (byte[] hash : usernameRequest.usernameHashes()) { - if (hash.length != USERNAME_HASH_LENGTH) { - throw new WebApplicationException(Response.status(422).build()); - } - } - - try { - final AccountsManager.UsernameReservation reservation = accounts.reserveUsernameHash( - auth.getAccount(), - usernameRequest.usernameHashes() - ); - return new ReserveUsernameHashResponse(reservation.reservedUsernameHash()); - } catch (final UsernameHashNotAvailableException e) { - throw new WebApplicationException(Status.CONFLICT); - } - } - - @Timed - @PUT - @Path("/username_hash/confirm") - @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) - public UsernameHashResponse confirmUsernameHash(@Auth AuthenticatedAccount auth, - @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String userAgent, - @NotNull @Valid ConfirmUsernameHashRequest confirmRequest) throws RateLimitExceededException { - rateLimiters.getUsernameSetLimiter().validate(auth.getAccount().getUuid()); - - try { - usernameHashZkProofVerifier.verifyProof(confirmRequest.zkProof(), confirmRequest.usernameHash()); - } catch (final BaseUsernameException e) { - throw new WebApplicationException(Response.status(422).build()); - } - - try { - final Account account = accounts.confirmReservedUsernameHash(auth.getAccount(), confirmRequest.usernameHash()); - return account - .getUsernameHash() - .map(UsernameHashResponse::new) - .orElseThrow(() -> new IllegalStateException("Could not get username after setting")); - } catch (final UsernameReservationNotFoundException e) { - throw new WebApplicationException(Status.CONFLICT); - } catch (final UsernameHashNotAvailableException e) { - throw new WebApplicationException(Status.GONE); - } - } - - @Timed - @GET - @Path("/username_hash/{usernameHash}") - @Produces(MediaType.APPLICATION_JSON) - @RateLimitedByIp(RateLimiters.Handle.USERNAME_LOOKUP) - public AccountIdentifierResponse lookupUsernameHash( - @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String userAgent, - @HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor, - @PathParam("usernameHash") final String usernameHash, - @Context final HttpServletRequest request) throws RateLimitExceededException { - - // Disallow clients from making authenticated requests to this endpoint - if (StringUtils.isNotBlank(request.getHeader("Authorization"))) { - throw new BadRequestException(); - } - - rateLimitByClientIp(rateLimiters.getUsernameLookupLimiter(), forwardedFor); - - final byte[] hash; - try { - hash = Base64.getUrlDecoder().decode(usernameHash); - } catch (IllegalArgumentException | AssertionError e) { - throw new WebApplicationException(Response.status(422).build()); - } - - if (hash.length != USERNAME_HASH_LENGTH) { - throw new WebApplicationException(Response.status(422).build()); - } - - return accounts - .getByUsernameHash(hash) - .map(Account::getUuid) - .map(AccountIdentifierResponse::new) - .orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND)); - } - - @HEAD - @Path("/account/{uuid}") - @RateLimitedByIp(RateLimiters.Handle.CHECK_ACCOUNT_EXISTENCE) - public Response accountExists( - @PathParam("uuid") final UUID uuid, - @Context HttpServletRequest request) throws RateLimitExceededException { - - // Disallow clients from making authenticated requests to this endpoint - if (StringUtils.isNotBlank(request.getHeader("Authorization"))) { - throw new BadRequestException(); - } - - final Status status = accounts.getByAccountIdentifier(uuid) - .or(() -> accounts.getByPhoneNumberIdentifier(uuid)) - .isPresent() ? Status.OK : Status.NOT_FOUND; - - return Response.status(status).build(); - } - - private void rateLimitByClientIp(final RateLimiter rateLimiter, final String forwardedFor) throws RateLimitExceededException { - final String mostRecentProxy = HeaderUtils.getMostRecentProxy(forwardedFor) - .orElseThrow(() -> { - // Missing/malformed Forwarded-For, so we cannot check for a rate-limit. - // This shouldn't happen, so conservatively assume we're over the rate-limit - // and indicate that the client should retry - logger.error("Missing/bad Forwarded-For: {}", forwardedFor); - return new RateLimitExceededException(Duration.ofHours(1), true); - }); - - rateLimiter.validate(mostRecentProxy); - } - - @VisibleForTesting - static boolean pushChallengeMatches( - final String number, - final Optional pushChallenge, - final Optional storedVerificationCode) { - - final String countryCode = Util.getCountryCode(number); - final String region = Util.getRegion(number); - final Optional storedPushChallenge = storedVerificationCode.map(StoredVerificationCode::pushCode); - - final boolean match = Optionals.zipWith(pushChallenge, storedPushChallenge, String::equals).orElse(false); - - Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME, - COUNTRY_CODE_TAG_NAME, countryCode, - REGION_TAG_NAME, region, - REGION_CODE_TAG_NAME, region, - CHALLENGE_PRESENT_TAG_NAME, Boolean.toString(pushChallenge.isPresent()), - CHALLENGE_MATCH_TAG_NAME, Boolean.toString(match)) - .increment(); - - return match; - } - - private boolean requiresCaptcha(String number, String transport, String forwardedFor, String sourceHost, boolean pushChallengeMatch) { - if (testDevices.containsKey(number)) { - return false; - } - - if (!pushChallengeMatch) { - return true; - } - - final String countryCode = Util.getCountryCode(number); - final String region = Util.getRegion(number); - - DynamicCaptchaConfiguration captchaConfig = dynamicConfigurationManager.getConfiguration() - .getCaptchaConfiguration(); - - boolean countryFiltered = captchaConfig.getSignupCountryCodes().contains(countryCode) || - captchaConfig.getSignupRegions().contains(region); - - try { - rateLimiters.getSmsVoiceIpLimiter().validate(sourceHost); - } catch (RateLimitExceededException e) { - logger.info("Rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor); - rateLimitedHostMeter.mark(); - - return true; - } - - try { - rateLimiters.getSmsVoicePrefixLimiter().validate(Util.getNumberPrefix(number)); - } catch (RateLimitExceededException e) { - logger.info("Prefix rate limit exceeded: {}, {}, {} ({})", transport, number, sourceHost, forwardedFor); - rateLimitedPrefixMeter.mark(); - - return true; - } - - if (countryFiltered) { - countryFilteredHostMeter.mark(); - return true; - } - - return false; - } - - @Timed - @DELETE - @Path("/me") - public void deleteAccount(@Auth DisabledPermittedAuthenticatedAccount auth) throws InterruptedException { - accounts.delete(auth.getAccount(), AccountsManager.DeletionReason.USER_REQUEST); - } - - private String generatePushChallenge() { - SecureRandom random = new SecureRandom(); - byte[] challenge = new byte[16]; - random.nextBytes(challenge); - - return HexFormat.of().formatHex(challenge); - } - - private byte[] createRegistrationSession(final Phonenumber.PhoneNumber phoneNumber) throws RateLimitExceededException { - - try { - return registrationServiceClient.createRegistrationSession(phoneNumber, REGISTRATION_RPC_TIMEOUT).join(); - } catch (final CompletionException e) { - rethrowRateLimitException(e); - - logger.debug("Failed to create session", e); - - // Meet legacy client expectations by "swallowing" session creation exceptions and proceeding as if we had created - // a new session. Future operations on this "session" will always fail, but that's the legacy behavior. - return new byte[16]; - } - } - - private void sendVerificationCode(final byte[] sessionId, - final MessageTransport messageTransport, - final ClientType clientType, - final Optional acceptLanguage) throws RateLimitExceededException { - - try { - registrationServiceClient.sendRegistrationCode(sessionId, - messageTransport, - clientType, - acceptLanguage.orElse(null), - REGISTRATION_RPC_TIMEOUT).join(); - } catch (final CompletionException e) { - // Note that, to meet legacy client expectations, we'll ONLY rethrow rate limit exceptions. All others will be - // swallowed silently. - rethrowRateLimitException(e); - - logger.debug("Failed to send verification code", e); - } - } - - private boolean checkVerificationCode(final byte[] sessionId, final String verificationCode) - throws RateLimitExceededException { - - try { - return registrationServiceClient.checkVerificationCode(sessionId, verificationCode, REGISTRATION_RPC_TIMEOUT).join(); - } catch (final CompletionException e) { - rethrowRateLimitException(e); - - // For legacy API compatibility, funnel all errors into the same return value - return false; - } - } - - private void rethrowRateLimitException(final CompletionException completionException) - throws RateLimitExceededException { - - Throwable cause = completionException; - - while (cause instanceof CompletionException) { - cause = cause.getCause(); - } - - if (cause instanceof RateLimitExceededException rateLimitExceededException) { - throw rateLimitExceededException; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java deleted file mode 100644 index e70af2523..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.net.HttpHeaders; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import java.util.Optional; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.Consumes; -import javax.ws.rs.ForbiddenException; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; -import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; -import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; -import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest; -import org.whispersystems.textsecuregcm.entities.MismatchedDevices; -import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest; -import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest; -import org.whispersystems.textsecuregcm.entities.StaleDevices; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; - -@Path("/v2/accounts") -public class AccountControllerV2 { - - private static final String CHANGE_NUMBER_COUNTER_NAME = name(AccountControllerV2.class, "changeNumber"); - private static final String VERIFICATION_TYPE_TAG_NAME = "verification"; - - private final AccountsManager accountsManager; - private final ChangeNumberManager changeNumberManager; - private final PhoneVerificationTokenManager phoneVerificationTokenManager; - private final RegistrationLockVerificationManager registrationLockVerificationManager; - private final RateLimiters rateLimiters; - - public AccountControllerV2(final AccountsManager accountsManager, final ChangeNumberManager changeNumberManager, - final PhoneVerificationTokenManager phoneVerificationTokenManager, - final RegistrationLockVerificationManager registrationLockVerificationManager, final RateLimiters rateLimiters) { - this.accountsManager = accountsManager; - this.changeNumberManager = changeNumberManager; - this.phoneVerificationTokenManager = phoneVerificationTokenManager; - this.registrationLockVerificationManager = registrationLockVerificationManager; - this.rateLimiters = rateLimiters; - } - - @Timed - @PUT - @Path("/number") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public AccountIdentityResponse changeNumber(@Auth final AuthenticatedAccount authenticatedAccount, - @NotNull @Valid final ChangeNumberRequest request, @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) - throws RateLimitExceededException, InterruptedException { - - if (!authenticatedAccount.getAuthenticatedDevice().isMaster()) { - throw new ForbiddenException(); - } - - final String number = request.number(); - - // Only verify and check reglock if there's a data change to be made... - if (!authenticatedAccount.getAccount().getNumber().equals(number)) { - - RateLimiter.adaptLegacyException(() -> rateLimiters.getRegistrationLimiter().validate(number)); - - final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(number, - request); - - final Optional existingAccount = accountsManager.getByE164(number); - - if (existingAccount.isPresent()) { - registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock()); - } - - Metrics.counter(CHANGE_NUMBER_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name()))) - .increment(); - } - - // ...but always attempt to make the change in case a client retries and needs to re-send messages - try { - final Account updatedAccount = changeNumberManager.changeNumber( - authenticatedAccount.getAccount(), - request.number(), - request.pniIdentityKey(), - request.devicePniSignedPrekeys(), - request.deviceMessages(), - request.pniRegistrationIds()); - - return new AccountIdentityResponse( - updatedAccount.getUuid(), - updatedAccount.getNumber(), - updatedAccount.getPhoneNumberIdentifier(), - updatedAccount.getUsernameHash().orElse(null), - updatedAccount.isStorageSupported()); - } catch (MismatchedDevicesException e) { - throw new WebApplicationException(Response.status(409) - .type(MediaType.APPLICATION_JSON_TYPE) - .entity(new MismatchedDevices(e.getMissingDevices(), - e.getExtraDevices())) - .build()); - } catch (StaleDevicesException e) { - throw new WebApplicationException(Response.status(410) - .type(MediaType.APPLICATION_JSON) - .entity(new StaleDevices(e.getStaleDevices())) - .build()); - } catch (IllegalArgumentException e) { - throw new BadRequestException(e); - } - } - - @Timed - @PUT - @Path("/phone_number_discoverability") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public void setPhoneNumberDiscoverability( - @Auth AuthenticatedAccount auth, - @NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability - ) { - accountsManager.update(auth.getAccount(), a -> a.setDiscoverableByPhoneNumber( - phoneNumberDiscoverability.discoverableByPhoneNumber())); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java deleted file mode 100644 index 47fb1f2a3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArtController.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import java.util.UUID; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; -import org.whispersystems.textsecuregcm.limits.RateLimiters; - -@Path("/v1/art") -public class ArtController { - private final ExternalServiceCredentialsGenerator artServiceCredentialsGenerator; - private final RateLimiters rateLimiters; - - public static ExternalServiceCredentialsGenerator credentialsGenerator(final ArtServiceConfiguration cfg) { - return ExternalServiceCredentialsGenerator - .builder(cfg.getUserAuthenticationTokenSharedSecret()) - .withUserDerivationKey(cfg.getUserAuthenticationTokenUserIdSecret()) - .prependUsername(false) - .truncateSignature(false) - .build(); - } - - public ArtController(RateLimiters rateLimiters, - ExternalServiceCredentialsGenerator artServiceCredentialsGenerator) { - this.artServiceCredentialsGenerator = artServiceCredentialsGenerator; - this.rateLimiters = rateLimiters; - } - - @Timed - @GET - @Path("/auth") - @Produces(MediaType.APPLICATION_JSON) - public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) - throws RateLimitExceededException { - final UUID uuid = auth.getAccount().getUuid(); - rateLimiters.getArtPackLimiter().validate(uuid); - return artServiceCredentialsGenerator.generateForUuid(uuid); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV2.java deleted file mode 100644 index fc8680c78..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV2.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import java.security.SecureRandom; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV2; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.s3.PolicySigner; -import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; -import org.whispersystems.textsecuregcm.util.Conversions; -import org.whispersystems.textsecuregcm.util.Pair; - -@Path("/v2/attachments") -public class AttachmentControllerV2 { - - private final PostPolicyGenerator policyGenerator; - private final PolicySigner policySigner; - private final RateLimiter rateLimiter; - - public AttachmentControllerV2(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, - String bucket) { - this.rateLimiter = rateLimiters.getAttachmentLimiter(); - this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey); - this.policySigner = new PolicySigner(accessSecret, region); - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/form/upload") - public AttachmentDescriptorV2 getAttachmentUploadForm(@Auth AuthenticatedAccount auth) - throws RateLimitExceededException { - rateLimiter.validate(auth.getAccount().getUuid()); - - ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); - long attachmentId = generateAttachmentId(); - String objectName = String.valueOf(attachmentId); - Pair policy = policyGenerator.createFor(now, String.valueOf(objectName), 100 * 1024 * 1024); - String signature = policySigner.getSignature(now, policy.second()); - - return new AttachmentDescriptorV2(attachmentId, objectName, policy.first(), - "private", "AWS4-HMAC-SHA256", - now.format(PostPolicyGenerator.AWS_DATE_TIME), - policy.second(), signature); - } - - private long generateAttachmentId() { - byte[] attachmentBytes = new byte[8]; - new SecureRandom().nextBytes(attachmentBytes); - - attachmentBytes[0] = (byte) (attachmentBytes[0] & 0x7F); - return Conversions.byteArrayToLong(attachmentBytes); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java deleted file mode 100644 index cb54b22dc..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV3.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.SecureRandom; -import java.security.spec.InvalidKeySpecException; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.util.Base64; -import java.util.Map; -import javax.annotation.Nonnull; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3; -import org.whispersystems.textsecuregcm.gcp.CanonicalRequest; -import org.whispersystems.textsecuregcm.gcp.CanonicalRequestGenerator; -import org.whispersystems.textsecuregcm.gcp.CanonicalRequestSigner; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; - -@Path("/v3/attachments") -public class AttachmentControllerV3 { - - @Nonnull - private final RateLimiter rateLimiter; - - @Nonnull - private final CanonicalRequestGenerator canonicalRequestGenerator; - - @Nonnull - private final CanonicalRequestSigner canonicalRequestSigner; - - @Nonnull - private final SecureRandom secureRandom; - - public AttachmentControllerV3(@Nonnull RateLimiters rateLimiters, @Nonnull String domain, @Nonnull String email, - int maxSizeInBytes, @Nonnull String pathPrefix, @Nonnull String rsaSigningKey) - throws IOException, InvalidKeyException, InvalidKeySpecException { - this.rateLimiter = rateLimiters.getAttachmentLimiter(); - this.canonicalRequestGenerator = new CanonicalRequestGenerator(domain, email, maxSizeInBytes, pathPrefix); - this.canonicalRequestSigner = new CanonicalRequestSigner(rsaSigningKey); - this.secureRandom = new SecureRandom(); - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/form/upload") - public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedAccount auth) - throws RateLimitExceededException { - rateLimiter.validate(auth.getAccount().getUuid()); - - final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); - final String key = generateAttachmentKey(); - final CanonicalRequest canonicalRequest = canonicalRequestGenerator.createFor(key, now); - - return new AttachmentDescriptorV3(2, key, getHeaderMap(canonicalRequest), - getSignedUploadLocation(canonicalRequest)); - } - - private String getSignedUploadLocation(@Nonnull CanonicalRequest canonicalRequest) { - return "https://" + canonicalRequest.getDomain() + canonicalRequest.getResourcePath() - + '?' + canonicalRequest.getCanonicalQuery() - + "&X-Goog-Signature=" + canonicalRequestSigner.sign(canonicalRequest); - } - - private static Map getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) { - return Map.of( - "host", canonicalRequest.getDomain(), - "x-goog-content-length-range", "1," + canonicalRequest.getMaxSizeInBytes(), - "x-goog-resumable", "start"); - } - - private String generateAttachmentKey() { - final byte[] bytes = new byte[15]; - secureRandom.nextBytes(bytes); - return Base64.getUrlEncoder().encodeToString(bytes); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CertificateController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CertificateController.java deleted file mode 100644 index 845d12e3e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CertificateController.java +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.Metrics; -import java.security.InvalidKeyException; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import javax.annotation.Nonnull; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.DefaultValue; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.CertificateGenerator; -import org.whispersystems.textsecuregcm.entities.DeliveryCertificate; -import org.whispersystems.textsecuregcm.entities.GroupCredentials; -import org.whispersystems.textsecuregcm.util.Util; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@Path("/v1/certificate") -public class CertificateController { - - private final CertificateGenerator certificateGenerator; - private final ServerZkAuthOperations serverZkAuthOperations; - private final Clock clock; - - @VisibleForTesting - public static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7); - private static final String GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME = name(CertificateGenerator.class, "generateCertificate"); - private static final String INCLUDE_E164_TAG_NAME = "includeE164"; - - public CertificateController( - @Nonnull CertificateGenerator certificateGenerator, - @Nonnull ServerZkAuthOperations serverZkAuthOperations, - @Nonnull Clock clock) { - this.certificateGenerator = Objects.requireNonNull(certificateGenerator); - this.serverZkAuthOperations = Objects.requireNonNull(serverZkAuthOperations); - this.clock = Objects.requireNonNull(clock); - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/delivery") - public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedAccount auth, - @QueryParam("includeE164") @DefaultValue("true") boolean includeE164) - throws InvalidKeyException { - - if (Util.isEmpty(auth.getAccount().getIdentityKey())) { - throw new WebApplicationException(Response.Status.BAD_REQUEST); - } - - Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164)) - .increment(); - - return new DeliveryCertificate( - certificateGenerator.createFor(auth.getAccount(), auth.getAuthenticatedDevice(), includeE164)); - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/group/{startRedemptionTime}/{endRedemptionTime}") - @Deprecated(forRemoval = true) // Clients should now use getGroupAuthenticationCredentials instead - // TODO Assess readiness for removal on or after 2022-11-01 - public GroupCredentials getAuthenticationCredentials(@Auth AuthenticatedAccount auth, - @PathParam("startRedemptionTime") int startRedemptionTime, - @PathParam("endRedemptionTime") int endRedemptionTime, - @QueryParam("identity") Optional identityType) { - if (startRedemptionTime > endRedemptionTime) { - throw new WebApplicationException(Response.Status.BAD_REQUEST); - } - final int currentDaysSinceEpoch = Util.currentDaysSinceEpoch(clock); - if (endRedemptionTime > currentDaysSinceEpoch + 7) { - throw new WebApplicationException(Response.Status.BAD_REQUEST); - } - if (startRedemptionTime < currentDaysSinceEpoch) { - throw new WebApplicationException(Response.Status.BAD_REQUEST); - } - - List credentials = new LinkedList<>(); - - final UUID identifier = identityType.map(String::toLowerCase).orElse("aci").equals("pni") ? - auth.getAccount().getPhoneNumberIdentifier() : - auth.getAccount().getUuid(); - - for (int i = startRedemptionTime; i <= endRedemptionTime; i++) { - credentials.add(new GroupCredentials.GroupCredential( - serverZkAuthOperations.issueAuthCredential(identifier, i).serialize(), - i)); - } - - return new GroupCredentials(credentials, null); - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/auth/group") - public GroupCredentials getGroupAuthenticationCredentials( - @Auth AuthenticatedAccount auth, - @QueryParam("redemptionStartSeconds") int startSeconds, - @QueryParam("redemptionEndSeconds") int endSeconds) { - - final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS); - final Instant redemptionStart = Instant.ofEpochSecond(startSeconds); - final Instant redemptionEnd = Instant.ofEpochSecond(endSeconds); - - if (redemptionStart.isAfter(redemptionEnd) || - redemptionStart.isBefore(startOfDay) || - redemptionEnd.isAfter(startOfDay.plus(MAX_REDEMPTION_DURATION)) || - !redemptionStart.equals(redemptionStart.truncatedTo(ChronoUnit.DAYS)) || - !redemptionEnd.equals(redemptionEnd.truncatedTo(ChronoUnit.DAYS))) { - - throw new BadRequestException(); - } - - final List credentials = new ArrayList<>(); - - Instant redemption = redemptionStart; - - UUID aci = auth.getAccount().getUuid(); - UUID pni = auth.getAccount().getPhoneNumberIdentifier(); - while (!redemption.isAfter(redemptionEnd)) { - credentials.add(new GroupCredentials.GroupCredential( - serverZkAuthOperations.issueAuthCredentialWithPni(aci, pni, redemption).serialize(), - (int) redemption.getEpochSecond())); - - redemption = redemption.plus(Duration.ofDays(1)); - } - - return new GroupCredentials(credentials, pni); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java deleted file mode 100644 index 9b8954a5e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.net.HttpHeaders; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import java.io.IOException; -import java.util.NoSuchElementException; -import javax.validation.Valid; -import javax.ws.rs.Consumes; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.AnswerChallengeRequest; -import org.whispersystems.textsecuregcm.entities.AnswerPushChallengeRequest; -import org.whispersystems.textsecuregcm.entities.AnswerRecaptchaChallengeRequest; -import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.util.HeaderUtils; - -@Path("/v1/challenge") -public class ChallengeController { - - private final RateLimitChallengeManager rateLimitChallengeManager; - - private static final String CHALLENGE_RESPONSE_COUNTER_NAME = name(ChallengeController.class, "challengeResponse"); - private static final String CHALLENGE_TYPE_TAG = "type"; - - public ChallengeController(final RateLimitChallengeManager rateLimitChallengeManager) { - this.rateLimitChallengeManager = rateLimitChallengeManager; - } - - @Timed - @PUT - @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) - public Response handleChallengeResponse(@Auth final AuthenticatedAccount auth, - @Valid final AnswerChallengeRequest answerRequest, - @HeaderParam(HttpHeaders.X_FORWARDED_FOR) final String forwardedFor, - @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException, IOException { - - Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)); - - try { - if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) { - tags = tags.and(CHALLENGE_TYPE_TAG, "push"); - - rateLimitChallengeManager.answerPushChallenge(auth.getAccount(), pushChallengeRequest.getChallenge()); - } else if (answerRequest instanceof AnswerRecaptchaChallengeRequest) { - tags = tags.and(CHALLENGE_TYPE_TAG, "recaptcha"); - - try { - final AnswerRecaptchaChallengeRequest recaptchaChallengeRequest = (AnswerRecaptchaChallengeRequest) answerRequest; - final String mostRecentProxy = HeaderUtils.getMostRecentProxy(forwardedFor).orElseThrow(); - - rateLimitChallengeManager.answerRecaptchaChallenge(auth.getAccount(), recaptchaChallengeRequest.getCaptcha(), - mostRecentProxy, userAgent); - - } catch (final NoSuchElementException e) { - return Response.status(400).build(); - } - } else { - tags = tags.and(CHALLENGE_TYPE_TAG, "unrecognized"); - } - } finally { - Metrics.counter(CHALLENGE_RESPONSE_COUNTER_NAME, tags).increment(); - } - - return Response.status(200).build(); - } - - @Timed - @POST - @Path("/push") - public Response requestPushChallenge(@Auth final AuthenticatedAccount auth) { - try { - rateLimitChallengeManager.sendPushChallenge(auth.getAccount()); - return Response.status(200).build(); - } catch (final NotPushRegisteredException e) { - return Response.status(404).build(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java deleted file mode 100644 index fbce3c8e1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import io.dropwizard.auth.Auth; -import java.security.SecureRandom; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.GET; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.server.ContainerRequest; -import org.whispersystems.textsecuregcm.auth.AuthEnablementRefreshRequirementProvider; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader; -import org.whispersystems.textsecuregcm.auth.ChangesDeviceEnabledState; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.DeviceInfo; -import org.whispersystems.textsecuregcm.entities.DeviceInfoList; -import org.whispersystems.textsecuregcm.entities.DeviceResponse; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.util.Util; -import org.whispersystems.textsecuregcm.util.VerificationCode; - -@Path("/v1/devices") -public class DeviceController { - - private static final int MAX_DEVICES = 6; - - private final StoredVerificationCodeManager pendingDevices; - private final AccountsManager accounts; - private final MessagesManager messages; - private final Keys keys; - private final RateLimiters rateLimiters; - private final Map maxDeviceConfiguration; - - public DeviceController(StoredVerificationCodeManager pendingDevices, - AccountsManager accounts, - MessagesManager messages, - Keys keys, - RateLimiters rateLimiters, - Map maxDeviceConfiguration) { - this.pendingDevices = pendingDevices; - this.accounts = accounts; - this.messages = messages; - this.keys = keys; - this.rateLimiters = rateLimiters; - this.maxDeviceConfiguration = maxDeviceConfiguration; - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - public DeviceInfoList getDevices(@Auth AuthenticatedAccount auth) { - List devices = new LinkedList<>(); - - for (Device device : auth.getAccount().getDevices()) { - devices.add(new DeviceInfo(device.getId(), device.getName(), - device.getLastSeen(), device.getCreated())); - } - - return new DeviceInfoList(devices); - } - - @Timed - @DELETE - @Path("/{device_id}") - @ChangesDeviceEnabledState - public void removeDevice(@Auth AuthenticatedAccount auth, @PathParam("device_id") long deviceId) { - Account account = auth.getAccount(); - if (auth.getAuthenticatedDevice().getId() != Device.MASTER_ID) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - messages.clear(account.getUuid(), deviceId); - account = accounts.update(account, a -> a.removeDevice(deviceId)); - keys.delete(account.getUuid(), deviceId); - // ensure any messages that came in after the first clear() are also removed - messages.clear(account.getUuid(), deviceId); - } - - @Timed - @GET - @Path("/provisioning/code") - @Produces(MediaType.APPLICATION_JSON) - public VerificationCode createDeviceToken(@Auth AuthenticatedAccount auth) - throws RateLimitExceededException, DeviceLimitExceededException { - - final Account account = auth.getAccount(); - - rateLimiters.getAllocateDeviceLimiter().validate(account.getUuid()); - - int maxDeviceLimit = MAX_DEVICES; - - if (maxDeviceConfiguration.containsKey(account.getNumber())) { - maxDeviceLimit = maxDeviceConfiguration.get(account.getNumber()); - } - - if (account.getEnabledDeviceCount() >= maxDeviceLimit) { - throw new DeviceLimitExceededException(account.getDevices().size(), MAX_DEVICES); - } - - if (auth.getAuthenticatedDevice().getId() != Device.MASTER_ID) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - VerificationCode verificationCode = generateVerificationCode(); - StoredVerificationCode storedVerificationCode = - new StoredVerificationCode(verificationCode.getVerificationCode(), System.currentTimeMillis(), null, null); - - pendingDevices.store(account.getNumber(), storedVerificationCode); - - return verificationCode; - } - - @Timed - @PUT - @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) - @Path("/{verification_code}") - @ChangesDeviceEnabledState - public DeviceResponse verifyDeviceToken(@PathParam("verification_code") String verificationCode, - @HeaderParam(HttpHeaders.AUTHORIZATION) BasicAuthorizationHeader authorizationHeader, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, - @NotNull @Valid AccountAttributes accountAttributes, - @Context ContainerRequest containerRequest) - throws RateLimitExceededException, DeviceLimitExceededException { - - String number = authorizationHeader.getUsername(); - String password = authorizationHeader.getPassword(); - - rateLimiters.getVerifyDeviceLimiter().validate(number); - - Optional storedVerificationCode = pendingDevices.getCodeForNumber(number); - - if (storedVerificationCode.isEmpty() || !storedVerificationCode.get().isValid(verificationCode)) { - throw new WebApplicationException(Response.status(403).build()); - } - - Optional account = accounts.getByE164(number); - - if (account.isEmpty()) { - throw new WebApplicationException(Response.status(403).build()); - } - - // Normally, the "do we need to refresh somebody's websockets" listener can do this on its own. In this case, - // we're not using the conventional authentication system, and so we need to give it a hint so it knows who the - // active user is and what their device states look like. - AuthEnablementRefreshRequirementProvider.setAccount(containerRequest, account.get()); - - int maxDeviceLimit = MAX_DEVICES; - - if (maxDeviceConfiguration.containsKey(account.get().getNumber())) { - maxDeviceLimit = maxDeviceConfiguration.get(account.get().getNumber()); - } - - if (account.get().getEnabledDeviceCount() >= maxDeviceLimit) { - throw new DeviceLimitExceededException(account.get().getDevices().size(), MAX_DEVICES); - } - - final DeviceCapabilities capabilities = accountAttributes.getCapabilities(); - if (capabilities != null && isCapabilityDowngrade(account.get(), capabilities)) { - throw new WebApplicationException(Response.status(409).build()); - } - - Device device = new Device(); - device.setName(accountAttributes.getName()); - device.setAuthTokenHash(SaltedTokenHash.generateFor(password)); - device.setFetchesMessages(accountAttributes.getFetchesMessages()); - device.setRegistrationId(accountAttributes.getRegistrationId()); - accountAttributes.getPhoneNumberIdentityRegistrationId().ifPresent(device::setPhoneNumberIdentityRegistrationId); - device.setLastSeen(Util.todayInMillis()); - device.setCreated(System.currentTimeMillis()); - device.setCapabilities(accountAttributes.getCapabilities()); - - final Account updatedAccount = accounts.update(account.get(), a -> { - device.setId(a.getNextDeviceId()); - messages.clear(a.getUuid(), device.getId()); - a.addDevice(device); - }); - - pendingDevices.remove(number); - - return new DeviceResponse(updatedAccount.getUuid(), updatedAccount.getPhoneNumberIdentifier(), device.getId()); - } - - @Timed - @PUT - @Path("/unauthenticated_delivery") - public void setUnauthenticatedDelivery(@Auth AuthenticatedAccount auth) { - assert (auth.getAuthenticatedDevice() != null); - // Deprecated - } - - @Timed - @PUT - @Path("/capabilities") - public void setCapabiltities(@Auth AuthenticatedAccount auth, @NotNull @Valid DeviceCapabilities capabilities) { - assert (auth.getAuthenticatedDevice() != null); - final long deviceId = auth.getAuthenticatedDevice().getId(); - accounts.updateDevice(auth.getAccount(), deviceId, d -> d.setCapabilities(capabilities)); - } - - @VisibleForTesting protected VerificationCode generateVerificationCode() { - SecureRandom random = new SecureRandom(); - int randomInt = 100000 + random.nextInt(900000); - return new VerificationCode(randomInt); - } - - private boolean isCapabilityDowngrade(Account account, DeviceCapabilities capabilities) { - boolean isDowngrade = false; - - isDowngrade |= account.isStoriesSupported() && !capabilities.isStories(); - isDowngrade |= account.isPniSupported() && !capabilities.isPni(); - isDowngrade |= account.isChangeNumberSupported() && !capabilities.isChangeNumber(); - isDowngrade |= account.isAnnouncementGroupSupported() && !capabilities.isAnnouncementGroup(); - isDowngrade |= account.isSenderKeySupported() && !capabilities.isSenderKey(); - isDowngrade |= account.isGiftBadgesSupported() && !capabilities.isGiftBadges(); - - return isDowngrade; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceLimitExceededException.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceLimitExceededException.java deleted file mode 100644 index 457c5d2cf..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceLimitExceededException.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - - -public class DeviceLimitExceededException extends Exception { - - private final int currentDevices; - private final int maxDevices; - - public DeviceLimitExceededException(int currentDevices, int maxDevices) { - this.currentDevices = currentDevices; - this.maxDevices = maxDevices; - } - - public int getCurrentDevices() { - return currentDevices; - } - - public int getMaxDevices() { - return maxDevices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DirectoryController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DirectoryController.java deleted file mode 100644 index ff6c2441e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DirectoryController.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import javax.ws.rs.Consumes; -import javax.ws.rs.GET; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.DirectoryClientConfiguration; - -@Path("/v1/directory") -public class DirectoryController { - - private final ExternalServiceCredentialsGenerator directoryServiceTokenGenerator; - - public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryClientConfiguration cfg) { - return ExternalServiceCredentialsGenerator - .builder(cfg.getUserAuthenticationTokenSharedSecret()) - .withUserDerivationKey(cfg.getUserAuthenticationTokenUserIdSecret()) - .build(); - } - - public DirectoryController(ExternalServiceCredentialsGenerator userTokenGenerator) { - this.directoryServiceTokenGenerator = userTokenGenerator; - } - - @Timed - @GET - @Path("/auth") - @Produces(MediaType.APPLICATION_JSON) - public Response getAuthToken(@Auth AuthenticatedAccount auth) { - return Response.ok().entity(directoryServiceTokenGenerator.generateFor(auth.getAccount().getNumber())).build(); - } - - @PUT - @Path("/feedback-v3/{status}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public Response setFeedback(@Auth AuthenticatedAccount auth) { - return Response.ok().build(); - } - - - @Timed - @GET - @Path("/{token}") - @Produces(MediaType.APPLICATION_JSON) - public Response getTokenPresence(@Auth AuthenticatedAccount auth) { - return Response.status(429).build(); - } - - @Timed - @PUT - @Path("/tokens") - @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) - public Response getContactIntersection(@Auth AuthenticatedAccount auth) { - return Response.status(429).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DirectoryV2Controller.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DirectoryV2Controller.java deleted file mode 100644 index ea221c195..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DirectoryV2Controller.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.auth.Auth; -import java.time.Clock; -import java.util.UUID; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration; - -@Path("/v2/directory") -public class DirectoryV2Controller { - - private final ExternalServiceCredentialsGenerator directoryServiceTokenGenerator; - - @VisibleForTesting - public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryV2ClientConfiguration cfg, - final Clock clock) { - return ExternalServiceCredentialsGenerator - .builder(cfg.userAuthenticationTokenSharedSecret()) - .withUserDerivationKey(cfg.userIdTokenSharedSecret()) - .prependUsername(false) - .withClock(clock) - .build(); - } - - public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryV2ClientConfiguration cfg) { - return credentialsGenerator(cfg, Clock.systemUTC()); - } - - public DirectoryV2Controller(ExternalServiceCredentialsGenerator userTokenGenerator) { - this.directoryServiceTokenGenerator = userTokenGenerator; - } - - @Timed - @GET - @Path("/auth") - @Produces(MediaType.APPLICATION_JSON) - public Response getAuthToken(@Auth AuthenticatedAccount auth) { - final UUID uuid = auth.getAccount().getUuid(); - final ExternalServiceCredentials credentials = directoryServiceTokenGenerator.generateForUuid(uuid); - return Response.ok().entity(credentials).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java deleted file mode 100644 index c6d363ae7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import java.time.Clock; -import java.time.Instant; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.ForkJoinPool; -import java.util.concurrent.ForkJoinPool.ManagedBlocker; -import java.util.function.Function; -import javax.annotation.Nonnull; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.Consumes; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.VerificationFailedException; -import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; -import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; -import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountBadge; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; - -@Path("/v1/donation") -public class DonationController { - - public interface ReceiptCredentialPresentationFactory { - ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException; - } - - private static final Logger logger = LoggerFactory.getLogger(DonationController.class); - - private final Clock clock; - private final ServerZkReceiptOperations serverZkReceiptOperations; - private final RedeemedReceiptsManager redeemedReceiptsManager; - private final AccountsManager accountsManager; - private final BadgesConfiguration badgesConfiguration; - private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory; - - public DonationController( - @Nonnull final Clock clock, - @Nonnull final ServerZkReceiptOperations serverZkReceiptOperations, - @Nonnull final RedeemedReceiptsManager redeemedReceiptsManager, - @Nonnull final AccountsManager accountsManager, - @Nonnull final BadgesConfiguration badgesConfiguration, - @Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) { - this.clock = Objects.requireNonNull(clock); - this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations); - this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager); - this.accountsManager = Objects.requireNonNull(accountsManager); - this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration); - this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory); - } - - @Timed - @POST - @Path("/redeem-receipt") - @Consumes(MediaType.APPLICATION_JSON) - @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) - public CompletionStage redeemReceipt( - @Auth final AuthenticatedAccount auth, - @NotNull @Valid final RedeemReceiptRequest request) { - return CompletableFuture.supplyAsync(() -> { - ReceiptCredentialPresentation receiptCredentialPresentation; - try { - receiptCredentialPresentation = receiptCredentialPresentationFactory.build( - request.getReceiptCredentialPresentation()); - } catch (InvalidInputException e) { - return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("invalid receipt credential presentation").type(MediaType.TEXT_PLAIN_TYPE).build()); - } - try { - serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation); - } catch (VerificationFailedException e) { - return CompletableFuture.completedFuture(Response.status(Status.BAD_REQUEST).entity("receipt credential presentation verification failed").type(MediaType.TEXT_PLAIN_TYPE).build()); - } - - final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial(); - final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime()); - final long receiptLevel = receiptCredentialPresentation.getReceiptLevel(); - final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel); - if (badgeId == null) { - return CompletableFuture.completedFuture(Response.serverError().entity("server does not recognize the requested receipt level").type(MediaType.TEXT_PLAIN_TYPE).build()); - } - final CompletionStage putStage = redeemedReceiptsManager.put( - receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.getAccount().getUuid()); - return putStage.thenApplyAsync(receiptMatched -> { - if (!receiptMatched) { - return Response.status(Status.BAD_REQUEST).entity("receipt serial is already redeemed").type(MediaType.TEXT_PLAIN_TYPE).build(); - } - - try { - ForkJoinPool.managedBlock(new ManagedBlocker() { - boolean done = false; - - @Override - public boolean block() { - final Optional optionalAccount = accountsManager.getByAccountIdentifier(auth.getAccount().getUuid()); - optionalAccount.ifPresent(account -> { - accountsManager.update(account, a -> { - a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible())); - if (request.isPrimary()) { - a.makeBadgePrimaryIfExists(clock, badgeId); - } - }); - }); - done = true; - return true; - } - - @Override - public boolean isReleasable() { - return done; - } - }); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return Response.serverError().build(); - } - - return Response.ok().build(); - }); - }).thenCompose(Function.identity()); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeepAliveController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeepAliveController.java deleted file mode 100644 index 87ef34a27..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeepAliveController.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.core.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.websocket.session.WebSocketSession; -import org.whispersystems.websocket.session.WebSocketSessionContext; - - -@Path("/v1/keepalive") -public class KeepAliveController { - - private final Logger logger = LoggerFactory.getLogger(KeepAliveController.class); - - private final ClientPresenceManager clientPresenceManager; - - private static final String NO_LOCAL_SUBSCRIPTION_COUNTER_NAME = name(KeepAliveController.class, "noLocalSubscription"); - - public KeepAliveController(final ClientPresenceManager clientPresenceManager) { - this.clientPresenceManager = clientPresenceManager; - } - - @Timed - @GET - public Response getKeepAlive(@Auth AuthenticatedAccount auth, - @WebSocketSession WebSocketSessionContext context) { - if (auth != null) { - if (!clientPresenceManager.isLocallyPresent(auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId())) { - logger.debug("***** No local subscription found for {}::{}; age = {}ms, User-Agent = {}", - auth.getAccount().getUuid(), auth.getAuthenticatedDevice().getId(), - System.currentTimeMillis() - context.getClient().getCreatedTimestamp(), - context.getClient().getUserAgent()); - - context.getClient().close(1000, "OK"); - - Metrics.counter(NO_LOCAL_SUBSCRIPTION_COUNTER_NAME, - Tags.of(UserAgentTagUtil.getPlatformTag(context.getClient().getUserAgent()))) - .increment(); - } - } - - return Response.ok().build(); - } - - @Timed - @GET - @Path("/provisioning") - public Response getProvisioningKeepAlive() { - return Response.ok().build(); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java deleted file mode 100644 index d36b9fa4e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.net.HttpHeaders; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.Consumes; -import javax.ws.rs.ForbiddenException; -import javax.ws.rs.GET; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.auth.Anonymous; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ChangesDeviceEnabledState; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.entities.PreKey; -import org.whispersystems.textsecuregcm.entities.PreKeyCount; -import org.whispersystems.textsecuregcm.entities.PreKeyResponse; -import org.whispersystems.textsecuregcm.entities.PreKeyResponseItem; -import org.whispersystems.textsecuregcm.entities.PreKeyState; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.util.Util; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@Path("/v2/keys") -public class KeysController { - - private final RateLimiters rateLimiters; - private final Keys keys; - private final AccountsManager accounts; - - private static final String PREKEY_REQUEST_COUNTER_NAME = name(KeysController.class, "preKeyGet"); - private static final String IDENTITY_KEY_CHANGE_FORBIDDEN_COUNTER_NAME = name(KeysController.class, "identityKeyChangeForbidden"); - - private static final String SOURCE_COUNTRY_TAG_NAME = "sourceCountry"; - private static final String INTERNATIONAL_TAG_NAME = "international"; - private static final String IDENTITY_TYPE_TAG_NAME = "identityType"; - private static final String HAS_IDENTITY_KEY_TAG_NAME = "hasIdentityKey"; - - public KeysController(RateLimiters rateLimiters, Keys keys, AccountsManager accounts) { - this.rateLimiters = rateLimiters; - this.keys = keys; - this.accounts = accounts; - } - - @GET - @Produces(MediaType.APPLICATION_JSON) - public PreKeyCount getStatus(@Auth final AuthenticatedAccount auth, - @QueryParam("identity") final Optional identityType) { - - int count = keys.getCount(getIdentifier(auth.getAccount(), identityType), auth.getAuthenticatedDevice().getId()); - - if (count > 0) { - count = count - 1; - } - - return new PreKeyCount(count); - } - - @Timed - @PUT - @Consumes(MediaType.APPLICATION_JSON) - @ChangesDeviceEnabledState - public void setKeys(@Auth final DisabledPermittedAuthenticatedAccount disabledPermittedAuth, - @NotNull @Valid final PreKeyState preKeys, - @QueryParam("identity") final Optional identityType, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) { - Account account = disabledPermittedAuth.getAccount(); - Device device = disabledPermittedAuth.getAuthenticatedDevice(); - boolean updateAccount = false; - - final boolean usePhoneNumberIdentity = usePhoneNumberIdentity(identityType); - - if (!preKeys.getSignedPreKey().equals(usePhoneNumberIdentity ? device.getPhoneNumberIdentitySignedPreKey() : device.getSignedPreKey())) { - updateAccount = true; - } - - if (!preKeys.getIdentityKey().equals(usePhoneNumberIdentity ? account.getPhoneNumberIdentityKey() : account.getIdentityKey())) { - updateAccount = true; - if (!device.isMaster()) { - final boolean hasIdentityKey = usePhoneNumberIdentity ? - StringUtils.isNotBlank(account.getPhoneNumberIdentityKey()) : - StringUtils.isNotBlank(account.getIdentityKey()); - - final Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)) - .and(HAS_IDENTITY_KEY_TAG_NAME, String.valueOf(hasIdentityKey)) - .and(IDENTITY_TYPE_TAG_NAME, usePhoneNumberIdentity ? "pni" : "aci"); - - Metrics.counter(IDENTITY_KEY_CHANGE_FORBIDDEN_COUNTER_NAME, tags).increment(); - - throw new ForbiddenException(); - } - } - - if (updateAccount) { - account = accounts.update(account, a -> { - a.getDevice(device.getId()).ifPresent(d -> { - if (usePhoneNumberIdentity) { - d.setPhoneNumberIdentitySignedPreKey(preKeys.getSignedPreKey()); - } else { - d.setSignedPreKey(preKeys.getSignedPreKey()); - } - }); - - if (usePhoneNumberIdentity) { - a.setPhoneNumberIdentityKey(preKeys.getIdentityKey()); - } else { - a.setIdentityKey(preKeys.getIdentityKey()); - } - }); - } - - keys.store(getIdentifier(account, identityType), device.getId(), preKeys.getPreKeys()); - } - - @Timed - @GET - @Path("/{identifier}/{device_id}") - @Produces(MediaType.APPLICATION_JSON) - public Response getDeviceKeys(@Auth Optional auth, - @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, - @PathParam("identifier") UUID targetUuid, - @PathParam("device_id") String deviceId, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) - throws RateLimitExceededException { - - if (!auth.isPresent() && !accessKey.isPresent()) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - final Optional account = auth.map(AuthenticatedAccount::getAccount); - - final Account target; - { - final Optional maybeTarget = accounts.getByAccountIdentifier(targetUuid) - .or(() -> accounts.getByPhoneNumberIdentifier(targetUuid)); - - OptionalAccess.verify(account, accessKey, maybeTarget, deviceId); - - target = maybeTarget.orElseThrow(); - } - - { - final String sourceCountryCode = account.map(a -> Util.getCountryCode(a.getNumber())).orElse("0"); - final String targetCountryCode = Util.getCountryCode(target.getNumber()); - - Metrics.counter(PREKEY_REQUEST_COUNTER_NAME, Tags.of( - SOURCE_COUNTRY_TAG_NAME, sourceCountryCode, - INTERNATIONAL_TAG_NAME, String.valueOf(!sourceCountryCode.equals(targetCountryCode)) - )).increment(); - } - - if (account.isPresent()) { - rateLimiters.getPreKeysLimiter().validate( - account.get().getUuid() + "." + auth.get().getAuthenticatedDevice().getId() + "__" + targetUuid - + "." + deviceId); - } - - final boolean usePhoneNumberIdentity = target.getPhoneNumberIdentifier().equals(targetUuid); - - Map preKeysByDeviceId = getLocalKeys(target, deviceId, usePhoneNumberIdentity); - List responseItems = new LinkedList<>(); - - for (Device device : target.getDevices()) { - if (device.isEnabled() && (deviceId.equals("*") || device.getId() == Long.parseLong(deviceId))) { - SignedPreKey signedPreKey = usePhoneNumberIdentity ? device.getPhoneNumberIdentitySignedPreKey() : device.getSignedPreKey(); - PreKey preKey = preKeysByDeviceId.get(device.getId()); - - if (signedPreKey != null || preKey != null) { - final int registrationId = usePhoneNumberIdentity ? - device.getPhoneNumberIdentityRegistrationId().orElse(device.getRegistrationId()) : - device.getRegistrationId(); - - responseItems.add(new PreKeyResponseItem(device.getId(), registrationId, signedPreKey, preKey)); - } - } - } - - final String identityKey = usePhoneNumberIdentity ? target.getPhoneNumberIdentityKey() : target.getIdentityKey(); - - if (responseItems.isEmpty()) return Response.status(404).build(); - else return Response.ok().entity(new PreKeyResponse(identityKey, responseItems)).build(); - } - - @Timed - @PUT - @Path("/signed") - @Consumes(MediaType.APPLICATION_JSON) - @ChangesDeviceEnabledState - public void setSignedKey(@Auth final AuthenticatedAccount auth, - @Valid final SignedPreKey signedPreKey, - @QueryParam("identity") final Optional identityType) { - - Device device = auth.getAuthenticatedDevice(); - - accounts.updateDevice(auth.getAccount(), device.getId(), d -> { - if (usePhoneNumberIdentity(identityType)) { - d.setPhoneNumberIdentitySignedPreKey(signedPreKey); - } else { - d.setSignedPreKey(signedPreKey); - } - }); - } - - @Timed - @GET - @Path("/signed") - @Produces(MediaType.APPLICATION_JSON) - public Optional getSignedKey(@Auth final AuthenticatedAccount auth, - @QueryParam("identity") final Optional identityType) { - - Device device = auth.getAuthenticatedDevice(); - SignedPreKey signedPreKey = usePhoneNumberIdentity(identityType) ? - device.getPhoneNumberIdentitySignedPreKey() : device.getSignedPreKey(); - - return Optional.ofNullable(signedPreKey); - } - - private static boolean usePhoneNumberIdentity(final Optional identityType) { - return "pni".equals(identityType.map(String::toLowerCase).orElse("aci")); - } - - private static UUID getIdentifier(final Account account, final Optional identityType) { - return usePhoneNumberIdentity(identityType) ? - account.getPhoneNumberIdentifier() : - account.getUuid(); - } - - private Map getLocalKeys(Account destination, String deviceIdSelector, final boolean usePhoneNumberIdentity) { - final Map preKeys; - - final UUID identifier = usePhoneNumberIdentity ? - destination.getPhoneNumberIdentifier() : - destination.getUuid(); - - if (deviceIdSelector.equals("*")) { - preKeys = new HashMap<>(); - - for (final Device device : destination.getDevices()) { - keys.take(identifier, device.getId()).ifPresent(preKey -> preKeys.put(device.getId(), preKey)); - } - } else { - try { - long deviceId = Long.parseLong(deviceIdSelector); - - preKeys = keys.take(identifier, deviceId) - .map(preKey -> Map.of(deviceId, preKey)) - .orElse(Collections.emptyMap()); - } catch (NumberFormatException e) { - throw new WebApplicationException(Response.status(422).build()); - } - } - - return preKeys; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java deleted file mode 100644 index 730a98364..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java +++ /dev/null @@ -1,781 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import com.google.protobuf.ByteString; -import io.dropwizard.auth.Auth; -import io.dropwizard.util.DataSize; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import java.security.MessageDigest; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.Callable; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.DefaultValue; -import javax.ws.rs.GET; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.Anonymous; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.CombinedUnidentifiedSenderAccessKeys; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.entities.AccountMismatchedDevices; -import org.whispersystems.textsecuregcm.entities.AccountStaleDevices; -import org.whispersystems.textsecuregcm.entities.IncomingMessage; -import org.whispersystems.textsecuregcm.entities.IncomingMessageList; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope.Type; -import org.whispersystems.textsecuregcm.entities.MismatchedDevices; -import org.whispersystems.textsecuregcm.entities.MultiRecipientMessage; -import org.whispersystems.textsecuregcm.entities.MultiRecipientMessage.Recipient; -import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity; -import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList; -import org.whispersystems.textsecuregcm.entities.SendMessageResponse; -import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse; -import org.whispersystems.textsecuregcm.entities.SpamReport; -import org.whispersystems.textsecuregcm.entities.StaleDevices; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.metrics.MessageMetrics; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; -import org.whispersystems.textsecuregcm.push.MessageSender; -import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.spam.FilterSpam; -import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.Util; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; -import org.whispersystems.textsecuregcm.websocket.WebSocketConnection; -import org.whispersystems.websocket.Stories; -import reactor.core.scheduler.Schedulers; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@Path("/v1/messages") -public class MessageController { - - private static final Logger logger = LoggerFactory.getLogger(MessageController.class); - - private final RateLimiters rateLimiters; - private final MessageSender messageSender; - private final ReceiptSender receiptSender; - private final AccountsManager accountsManager; - private final DeletedAccountsManager deletedAccountsManager; - private final MessagesManager messagesManager; - private final PushNotificationManager pushNotificationManager; - private final ReportMessageManager reportMessageManager; - private final ExecutorService multiRecipientMessageExecutor; - private final ReportSpamTokenProvider reportSpamTokenProvider; - - private static final String REJECT_OVERSIZE_MESSAGE_COUNTER = name(MessageController.class, "rejectOversizeMessage"); - private static final String SENT_MESSAGE_COUNTER_NAME = name(MessageController.class, "sentMessages"); - private static final String CONTENT_SIZE_DISTRIBUTION_NAME = name(MessageController.class, "messageContentSize"); - private static final String OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME = name(MessageController.class, "outgoingMessageListSizeBytes"); - private static final String RATE_LIMITED_MESSAGE_COUNTER_NAME = name(MessageController.class, "rateLimitedMessage"); - private static final String REJECT_INVALID_ENVELOPE_TYPE = name(MessageController.class, "rejectInvalidEnvelopeType"); - - private static final String EPHEMERAL_TAG_NAME = "ephemeral"; - private static final String SENDER_TYPE_TAG_NAME = "senderType"; - private static final String SENDER_COUNTRY_TAG_NAME = "senderCountry"; - private static final String RATE_LIMIT_REASON_TAG_NAME = "rateLimitReason"; - private static final String ENVELOPE_TYPE_TAG_NAME = "envelopeType"; - - private static final String SENDER_TYPE_IDENTIFIED = "identified"; - private static final String SENDER_TYPE_UNIDENTIFIED = "unidentified"; - private static final String SENDER_TYPE_SELF = "self"; - - @VisibleForTesting - static final long MAX_MESSAGE_SIZE = DataSize.kibibytes(256).toBytes(); - - public MessageController( - RateLimiters rateLimiters, - MessageSender messageSender, - ReceiptSender receiptSender, - AccountsManager accountsManager, - DeletedAccountsManager deletedAccountsManager, - MessagesManager messagesManager, - PushNotificationManager pushNotificationManager, - ReportMessageManager reportMessageManager, - @Nonnull ExecutorService multiRecipientMessageExecutor, - @Nonnull ReportSpamTokenProvider reportSpamTokenProvider) { - this.rateLimiters = rateLimiters; - this.messageSender = messageSender; - this.receiptSender = receiptSender; - this.accountsManager = accountsManager; - this.deletedAccountsManager = deletedAccountsManager; - this.messagesManager = messagesManager; - this.pushNotificationManager = pushNotificationManager; - this.reportMessageManager = reportMessageManager; - this.multiRecipientMessageExecutor = Objects.requireNonNull(multiRecipientMessageExecutor); - this.reportSpamTokenProvider = reportSpamTokenProvider; - } - - @Timed - @Path("/{destination}") - @PUT - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @FilterSpam - public Response sendMessage(@Auth Optional source, - @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, - @HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor, - @PathParam("destination") UUID destinationUuid, - @QueryParam("story") boolean isStory, - @NotNull @Valid IncomingMessageList messages, - @Context ContainerRequestContext context - ) - throws RateLimitExceededException { - - if (source.isEmpty() && accessKey.isEmpty() && !isStory) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - final String senderType; - - if (source.isPresent()) { - if (source.get().getAccount().isIdentifiedBy(destinationUuid)) { - senderType = SENDER_TYPE_SELF; - } else { - senderType = SENDER_TYPE_IDENTIFIED; - } - } else { - senderType = SENDER_TYPE_UNIDENTIFIED; - } - - final Optional spamReportToken; - if (senderType.equals(SENDER_TYPE_IDENTIFIED)) { - spamReportToken = reportSpamTokenProvider.makeReportSpamToken(context); - } else { - spamReportToken = Optional.empty(); - } - - for (final IncomingMessage message : messages.messages()) { - - int contentLength = 0; - - if (!Util.isEmpty(message.content())) { - contentLength += message.content().length(); - } - - validateContentLength(contentLength, userAgent); - validateEnvelopeType(message.type(), userAgent); - } - - try { - boolean isSyncMessage = source.isPresent() && source.get().getAccount().isIdentifiedBy(destinationUuid); - - Optional destination; - - if (!isSyncMessage) { - destination = accountsManager.getByAccountIdentifier(destinationUuid) - .or(() -> accountsManager.getByPhoneNumberIdentifier(destinationUuid)); - } else { - destination = source.map(AuthenticatedAccount::getAccount); - } - - // Stories will be checked by the client; we bypass access checks here for stories. - if (!isStory) { - OptionalAccess.verify(source.map(AuthenticatedAccount::getAccount), accessKey, destination); - } - - boolean needsSync = !isSyncMessage && source.isPresent() && source.get().getAccount().getEnabledDeviceCount() > 1; - - // We return 200 when stories are sent to a non-existent account. Since story sends bypass OptionalAccess.verify - // we leak information about whether a destination UUID exists if we return any other code (e.g. 404) from - // these requests. - if (isStory && destination.isEmpty()) { - return Response.ok(new SendMessageResponse(needsSync)).build(); - } - - // if destination is empty we would either throw an exception in OptionalAccess.verify when isStory is false - // or else return a 200 response when isStory is true. - assert destination.isPresent(); - - if (source.isPresent() && !isSyncMessage) { - checkMessageRateLimit(source.get(), destination.get(), userAgent); - } - - if (isStory) { - checkStoryRateLimit(destination.get()); - } - - final Set excludedDeviceIds; - - if (isSyncMessage) { - excludedDeviceIds = Set.of(source.get().getAuthenticatedDevice().getId()); - } else { - excludedDeviceIds = Collections.emptySet(); - } - - DestinationDeviceValidator.validateCompleteDeviceList(destination.get(), - messages.messages().stream().map(IncomingMessage::destinationDeviceId).collect(Collectors.toSet()), - excludedDeviceIds); - - DestinationDeviceValidator.validateRegistrationIds(destination.get(), - messages.messages(), - IncomingMessage::destinationDeviceId, - IncomingMessage::destinationRegistrationId, - destination.get().getPhoneNumberIdentifier().equals(destinationUuid)); - - final List tags = List.of(UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(EPHEMERAL_TAG_NAME, String.valueOf(messages.online())), - Tag.of(SENDER_TYPE_TAG_NAME, senderType)); - - for (IncomingMessage incomingMessage : messages.messages()) { - Optional destinationDevice = destination.get().getDevice(incomingMessage.destinationDeviceId()); - - if (destinationDevice.isPresent()) { - Metrics.counter(SENT_MESSAGE_COUNTER_NAME, tags).increment(); - sendIndividualMessage( - source, - destination.get(), - destinationDevice.get(), - destinationUuid, - messages.timestamp(), - messages.online(), - isStory, - messages.urgent(), - incomingMessage, - userAgent, - spamReportToken); - } - } - - return Response.ok(new SendMessageResponse(needsSync)).build(); - } catch (NoSuchUserException e) { - throw new WebApplicationException(Response.status(404).build()); - } catch (MismatchedDevicesException e) { - throw new WebApplicationException(Response.status(409) - .type(MediaType.APPLICATION_JSON_TYPE) - .entity(new MismatchedDevices(e.getMissingDevices(), - e.getExtraDevices())) - .build()); - } catch (StaleDevicesException e) { - throw new WebApplicationException(Response.status(410) - .type(MediaType.APPLICATION_JSON) - .entity(new StaleDevices(e.getStaleDevices())) - .build()); - } - } - - - /** - * Build mapping of accounts to devices/registration IDs. - * - * @param multiRecipientMessage - * @param uuidToAccountMap - * @return - */ - private Map>> buildDeviceIdAndRegistrationIdMap( - MultiRecipientMessage multiRecipientMessage, - Map uuidToAccountMap - ) { - - return Arrays.stream(multiRecipientMessage.getRecipients()) - // for normal messages, all recipients UUIDs are in the map, - // but story messages might specify inactive UUIDs, which we - // have previously filtered - .filter(r -> uuidToAccountMap.containsKey(r.getUuid())) - .collect(Collectors.toMap( - recipient -> uuidToAccountMap.get(recipient.getUuid()), - recipient -> new HashSet<>( - Collections.singletonList(new Pair<>(recipient.getDeviceId(), recipient.getRegistrationId()))), - (a, b) -> { - a.addAll(b); - return a; - } - )); - } - - @Timed - @Path("/multi_recipient") - @PUT - @Consumes(MultiRecipientMessageProvider.MEDIA_TYPE) - @Produces(MediaType.APPLICATION_JSON) - @FilterSpam - public Response sendMultiRecipientMessage( - @HeaderParam(OptionalAccess.UNIDENTIFIED) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, - @HeaderParam(HttpHeaders.X_FORWARDED_FOR) String forwardedFor, - @QueryParam("online") boolean online, - @QueryParam("ts") long timestamp, - @QueryParam("urgent") @DefaultValue("true") final boolean isUrgent, - @QueryParam("story") boolean isStory, - @NotNull @Valid MultiRecipientMessage multiRecipientMessage) { - - // we skip "missing" accounts when story=true. - // otherwise, we return a 404 status code. - final Function> accountFinder = uuid -> { - Optional res = accountsManager.getByAccountIdentifier(uuid); - if (!isStory && res.isEmpty()) { - throw new WebApplicationException(Status.NOT_FOUND); - } - return res.stream(); - }; - - // build a map from UUID to accounts - Map uuidToAccountMap = - Arrays.stream(multiRecipientMessage.getRecipients()) - .map(Recipient::getUuid) - .distinct() - .flatMap(accountFinder) - .collect(Collectors.toUnmodifiableMap( - Account::getUuid, - Function.identity())); - - // Stories will be checked by the client; we bypass access checks here for stories. - if (!isStory) { - checkAccessKeys(accessKeys, uuidToAccountMap); - } - - final Map>> accountToDeviceIdAndRegistrationIdMap = - buildDeviceIdAndRegistrationIdMap(multiRecipientMessage, uuidToAccountMap); - - // We might filter out all the recipients of a story (if none have enabled stories). - // In this case there is no error so we should just return 200 now. - if (isStory && accountToDeviceIdAndRegistrationIdMap.isEmpty()) { - return Response.ok(new SendMultiRecipientMessageResponse(new LinkedList<>())).build(); - } - - Collection accountMismatchedDevices = new ArrayList<>(); - Collection accountStaleDevices = new ArrayList<>(); - uuidToAccountMap.values().forEach(account -> { - - if (isStory) { - checkStoryRateLimit(account); - } - - Set deviceIds = accountToDeviceIdAndRegistrationIdMap - .getOrDefault(account, Collections.emptySet()) - .stream() - .map(Pair::first) - .collect(Collectors.toSet()); - - try { - DestinationDeviceValidator.validateCompleteDeviceList(account, deviceIds, Collections.emptySet()); - - // Multi-recipient messages are always sealed-sender messages, and so can never be sent to a phone number - // identity - DestinationDeviceValidator.validateRegistrationIds( - account, - accountToDeviceIdAndRegistrationIdMap.get(account).stream(), - false); - } catch (MismatchedDevicesException e) { - accountMismatchedDevices.add(new AccountMismatchedDevices(account.getUuid(), - new MismatchedDevices(e.getMissingDevices(), e.getExtraDevices()))); - } catch (StaleDevicesException e) { - accountStaleDevices.add(new AccountStaleDevices(account.getUuid(), new StaleDevices(e.getStaleDevices()))); - } - }); - if (!accountMismatchedDevices.isEmpty()) { - return Response - .status(409) - .type(MediaType.APPLICATION_JSON_TYPE) - .entity(accountMismatchedDevices) - .build(); - } - if (!accountStaleDevices.isEmpty()) { - return Response - .status(410) - .type(MediaType.APPLICATION_JSON) - .entity(accountStaleDevices) - .build(); - } - - List uuids404 = Collections.synchronizedList(new ArrayList<>()); - - try { - final Counter sentMessageCounter = Metrics.counter(SENT_MESSAGE_COUNTER_NAME, Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(EPHEMERAL_TAG_NAME, String.valueOf(online)), - Tag.of(SENDER_TYPE_TAG_NAME, SENDER_TYPE_UNIDENTIFIED))); - - multiRecipientMessageExecutor.invokeAll(Arrays.stream(multiRecipientMessage.getRecipients()) - .map(recipient -> (Callable) () -> { - Account destinationAccount = uuidToAccountMap.get(recipient.getUuid()); - - // we asserted this must exist in validateCompleteDeviceList - Device destinationDevice = destinationAccount.getDevice(recipient.getDeviceId()).orElseThrow(); - sentMessageCounter.increment(); - try { - sendCommonPayloadMessage(destinationAccount, destinationDevice, timestamp, online, isStory, isUrgent, - recipient, multiRecipientMessage.getCommonPayload()); - } catch (NoSuchUserException e) { - uuids404.add(destinationAccount.getUuid()); - } - return null; - }) - .collect(Collectors.toList())); - } catch (InterruptedException e) { - logger.error("interrupted while delivering multi-recipient messages", e); - return Response.serverError().entity("interrupted during delivery").build(); - } - return Response.ok(new SendMultiRecipientMessageResponse(uuids404)).build(); - } - - private void checkAccessKeys(CombinedUnidentifiedSenderAccessKeys accessKeys, Map uuidToAccountMap) { - // We should not have null access keys when checking access; bail out early. - if (accessKeys == null) { - throw new WebApplicationException(Status.UNAUTHORIZED); - } - AtomicBoolean throwUnauthorized = new AtomicBoolean(false); - byte[] empty = new byte[16]; - final Optional UNRESTRICTED_UNIDENTIFIED_ACCESS_KEY = Optional.of(new byte[16]); - byte[] combinedUnknownAccessKeys = uuidToAccountMap.values().stream() - .map(account -> { - if (account.isUnrestrictedUnidentifiedAccess()) { - return UNRESTRICTED_UNIDENTIFIED_ACCESS_KEY; - } else { - return account.getUnidentifiedAccessKey(); - } - }) - .map(accessKey -> { - if (accessKey.isEmpty()) { - throwUnauthorized.set(true); - return empty; - } - return accessKey.get(); - }) - .reduce(new byte[16], (bytes, bytes2) -> { - if (bytes.length != bytes2.length) { - throwUnauthorized.set(true); - return bytes; - } - for (int i = 0; i < bytes.length; i++) { - bytes[i] ^= bytes2[i]; - } - return bytes; - }); - if (throwUnauthorized.get() - || !MessageDigest.isEqual(combinedUnknownAccessKeys, accessKeys.getAccessKeys())) { - throw new WebApplicationException(Status.UNAUTHORIZED); - } - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture getPendingMessages(@Auth AuthenticatedAccount auth, - @HeaderParam(Stories.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) { - - boolean shouldReceiveStories = Stories.parseReceiveStoriesHeader(receiveStoriesHeader); - - pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), auth.getAuthenticatedDevice(), userAgent); - - return messagesManager.getMessagesForDevice( - auth.getAccount().getUuid(), - auth.getAuthenticatedDevice().getId(), - false) - .map(messagesAndHasMore -> { - Stream envelopes = messagesAndHasMore.first().stream(); - if (!shouldReceiveStories) { - envelopes = envelopes.filter(e -> !e.getStory()); - } - - final OutgoingMessageEntityList messages = new OutgoingMessageEntityList(envelopes - .map(OutgoingMessageEntity::fromEnvelope) - .peek( - outgoingMessageEntity -> MessageMetrics.measureAccountOutgoingMessageUuidMismatches(auth.getAccount(), - outgoingMessageEntity)) - .collect(Collectors.toList()), - messagesAndHasMore.second()); - - String platform; - - try { - platform = UserAgentUtil.parseUserAgentString(userAgent).getPlatform().name().toLowerCase(); - } catch (final UnrecognizedUserAgentException ignored) { - platform = "unrecognized"; - } - - Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, "platform", platform) - .record(estimateMessageListSizeBytes(messages)); - - return messages; - }) - .timeout(Duration.ofSeconds(5)) - .subscribeOn(Schedulers.boundedElastic()) - .toFuture(); - } - - private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) { - long size = 0; - - for (final OutgoingMessageEntity message : messageList.messages()) { - size += message.content() == null ? 0 : message.content().length; - size += message.sourceUuid() == null ? 0 : 36; - } - - return size; - } - - @Timed - @DELETE - @Path("/uuid/{uuid}") - public CompletableFuture removePendingMessage(@Auth AuthenticatedAccount auth, @PathParam("uuid") UUID uuid) { - return messagesManager.delete( - auth.getAccount().getUuid(), - auth.getAuthenticatedDevice().getId(), - uuid, - null) - .thenAccept(maybeDeletedMessage -> { - maybeDeletedMessage.ifPresent(deletedMessage -> { - - WebSocketConnection.recordMessageDeliveryDuration(deletedMessage.getTimestamp(), - auth.getAuthenticatedDevice()); - - if (deletedMessage.hasSourceUuid() && deletedMessage.getType() != Type.SERVER_DELIVERY_RECEIPT) { - try { - receiptSender.sendReceipt( - UUID.fromString(deletedMessage.getDestinationUuid()), auth.getAuthenticatedDevice().getId(), - UUID.fromString(deletedMessage.getSourceUuid()), deletedMessage.getTimestamp()); - } catch (Exception e) { - logger.warn("Failed to send delivery receipt", e); - } - } - }); - }); - } - - @Timed - @POST - @Consumes(MediaType.APPLICATION_JSON) - @Path("/report/{source}/{messageGuid}") - public Response reportSpamMessage( - @Auth AuthenticatedAccount auth, - @PathParam("source") String source, - @PathParam("messageGuid") UUID messageGuid, - @Nullable @Valid SpamReport spamReport, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent - ) { - - final Optional sourceNumber; - final Optional sourceAci; - final Optional sourcePni; - if (source.startsWith("+")) { - sourceNumber = Optional.of(source); - final Optional maybeAccount = accountsManager.getByE164(source); - if (maybeAccount.isPresent()) { - sourceAci = maybeAccount.map(Account::getUuid); - sourcePni = maybeAccount.map(Account::getPhoneNumberIdentifier); - } else { - sourceAci = deletedAccountsManager.findDeletedAccountAci(source); - sourcePni = Optional.ofNullable(accountsManager.getPhoneNumberIdentifier(source)); - } - } else { - sourceAci = Optional.of(UUID.fromString(source)); - - final Optional sourceAccount = accountsManager.getByAccountIdentifier(sourceAci.get()); - - if (sourceAccount.isEmpty()) { - logger.warn("Could not find source: {}", sourceAci.get()); - sourceNumber = deletedAccountsManager.findDeletedAccountE164(sourceAci.get()); - sourcePni = sourceNumber.map(accountsManager::getPhoneNumberIdentifier); - } else { - sourceNumber = sourceAccount.map(Account::getNumber); - sourcePni = sourceAccount.map(Account::getPhoneNumberIdentifier); - } - } - - UUID spamReporterUuid = auth.getAccount().getUuid(); - - // spam report token is optional, but if provided ensure it is valid base64. - final Optional maybeSpamReportToken = - spamReport != null ? Optional.ofNullable(spamReport.token()) : Optional.empty(); - - reportMessageManager.report(sourceNumber, sourceAci, sourcePni, messageGuid, spamReporterUuid, maybeSpamReportToken, userAgent); - - return Response.status(Status.ACCEPTED) - .build(); - } - - private void sendIndividualMessage( - Optional source, - Account destinationAccount, - Device destinationDevice, - UUID destinationUuid, - long timestamp, - boolean online, - boolean story, - boolean urgent, - IncomingMessage incomingMessage, - String userAgentString, - Optional spamReportToken) - throws NoSuchUserException { - try { - final Envelope envelope; - - try { - Account sourceAccount = source.map(AuthenticatedAccount::getAccount).orElse(null); - Long sourceDeviceId = source.map(account -> account.getAuthenticatedDevice().getId()).orElse(null); - envelope = incomingMessage.toEnvelope( - destinationUuid, - sourceAccount, - sourceDeviceId, - timestamp == 0 ? System.currentTimeMillis() : timestamp, - story, - urgent, - spamReportToken.orElse(null)); - } catch (final IllegalArgumentException e) { - logger.warn("Received bad envelope type {} from {}", incomingMessage.type(), userAgentString); - throw new BadRequestException(e); - } - - messageSender.sendMessage(destinationAccount, destinationDevice, envelope, online); - } catch (NotPushRegisteredException e) { - if (destinationDevice.isMaster()) throw new NoSuchUserException(e); - else logger.debug("Not registered", e); - } - } - - private void sendCommonPayloadMessage(Account destinationAccount, - Device destinationDevice, - long timestamp, - boolean online, - boolean story, - boolean urgent, - Recipient recipient, - byte[] commonPayload) throws NoSuchUserException { - try { - Envelope.Builder messageBuilder = Envelope.newBuilder(); - long serverTimestamp = System.currentTimeMillis(); - byte[] recipientKeyMaterial = recipient.getPerRecipientKeyMaterial(); - - byte[] payload = new byte[1 + recipientKeyMaterial.length + commonPayload.length]; - payload[0] = MultiRecipientMessageProvider.VERSION; - System.arraycopy(recipientKeyMaterial, 0, payload, 1, recipientKeyMaterial.length); - System.arraycopy(commonPayload, 0, payload, 1 + recipientKeyMaterial.length, commonPayload.length); - - messageBuilder - .setType(Type.UNIDENTIFIED_SENDER) - .setTimestamp(timestamp == 0 ? serverTimestamp : timestamp) - .setServerTimestamp(serverTimestamp) - .setContent(ByteString.copyFrom(payload)) - .setStory(story) - .setUrgent(urgent) - .setDestinationUuid(destinationAccount.getUuid().toString()); - - messageSender.sendMessage(destinationAccount, destinationDevice, messageBuilder.build(), online); - } catch (NotPushRegisteredException e) { - if (destinationDevice.isMaster()) { - throw new NoSuchUserException(e); - } else { - logger.debug("Not registered", e); - } - } - } - - private void checkStoryRateLimit(Account destination) { - try { - rateLimiters.getMessagesLimiter().validate(destination.getUuid()); - } catch (final RateLimitExceededException e) { - } - } - - private void checkMessageRateLimit(AuthenticatedAccount source, Account destination, String userAgent) - throws RateLimitExceededException { - final String senderCountryCode = Util.getCountryCode(source.getAccount().getNumber()); - - try { - rateLimiters.getMessagesLimiter().validate(source.getAccount().getUuid(), destination.getUuid()); - } catch (final RateLimitExceededException e) { - Metrics.counter(RATE_LIMITED_MESSAGE_COUNTER_NAME, - Tags.of( - UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(SENDER_COUNTRY_TAG_NAME, senderCountryCode), - Tag.of(RATE_LIMIT_REASON_TAG_NAME, "singleDestinationRate"))).increment(); - - throw e; - } - } - - private void validateContentLength(final int contentLength, final String userAgent) { - Metrics.summary(CONTENT_SIZE_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))) - .record(contentLength); - - if (contentLength > MAX_MESSAGE_SIZE) { - Metrics.counter(REJECT_OVERSIZE_MESSAGE_COUNTER, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))) - .increment(); - throw new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE); - } - - } - - private void validateEnvelopeType(final int type, final String userAgent) { - if (type == Type.SERVER_DELIVERY_RECEIPT_VALUE) { - Metrics.counter(REJECT_INVALID_ENVELOPE_TYPE, - Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), Tag.of(ENVELOPE_TYPE_TAG_NAME, String.valueOf(type)))) - .increment(); - throw new BadRequestException("reserved envelope type"); - } - } - - public static Optional getMessageContent(IncomingMessage message) { - if (Util.isEmpty(message.content())) return Optional.empty(); - - try { - return Optional.of(Base64.getDecoder().decode(message.content())); - } catch (IllegalArgumentException e) { - logger.debug("Bad B64", e); - return Optional.empty(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MismatchedDevicesException.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MismatchedDevicesException.java deleted file mode 100644 index 4f8745e1c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/MismatchedDevicesException.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import java.util.List; - -public class MismatchedDevicesException extends Exception { - - private final List missingDevices; - private final List extraDevices; - - public MismatchedDevicesException(List missingDevices, List extraDevices) { - this.missingDevices = missingDevices; - this.extraDevices = extraDevices; - } - - public List getMissingDevices() { - return missingDevices; - } - - public List getExtraDevices() { - return extraDevices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/NoSuchUserException.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/NoSuchUserException.java deleted file mode 100644 index 68c3c58b0..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/NoSuchUserException.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import java.util.UUID; - -public class NoSuchUserException extends Exception { - - public NoSuchUserException(final UUID uuid) { - super(uuid.toString()); - } - - public NoSuchUserException(Exception e) { - super(e); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/PaymentsController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/PaymentsController.java deleted file mode 100644 index ed13445d7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/PaymentsController.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; -import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList; - -@Path("/v1/payments") -public class PaymentsController { - - private final ExternalServiceCredentialsGenerator paymentsServiceCredentialsGenerator; - private final CurrencyConversionManager currencyManager; - - - public static ExternalServiceCredentialsGenerator credentialsGenerator(final PaymentsServiceConfiguration cfg) { - return ExternalServiceCredentialsGenerator - .builder(cfg.getUserAuthenticationTokenSharedSecret()) - .prependUsername(true) - .build(); - } - - public PaymentsController(final CurrencyConversionManager currencyManager, - final ExternalServiceCredentialsGenerator paymentsServiceCredentialsGenerator) { - this.currencyManager = currencyManager; - this.paymentsServiceCredentialsGenerator = paymentsServiceCredentialsGenerator; - } - - @Timed - @GET - @Path("/auth") - @Produces(MediaType.APPLICATION_JSON) - public ExternalServiceCredentials getAuth(final @Auth AuthenticatedAccount auth) { - return paymentsServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid()); - } - - @Timed - @GET - @Path("/conversions") - @Produces(MediaType.APPLICATION_JSON) - public CurrencyConversionEntityList getConversions(final @Auth AuthenticatedAccount auth) { - return currencyManager.getCurrencyConversions().orElseThrow(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java deleted file mode 100644 index 0ba8a9c9d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java +++ /dev/null @@ -1,687 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import io.vavr.Tuple; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.HexFormat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.function.Function; -import java.util.stream.Collectors; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.Consumes; -import javax.ws.rs.DefaultValue; -import javax.ws.rs.ForbiddenException; -import javax.ws.rs.GET; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.NotAuthorizedException; -import javax.ws.rs.NotFoundException; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.ProcessingException; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.apache.commons.lang3.StringUtils; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.VerificationFailedException; -import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; -import org.signal.libsignal.zkgroup.profiles.PniCredentialResponse; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialResponse; -import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.Anonymous; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum; -import org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter; -import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; -import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.BaseProfileResponse; -import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckRequest; -import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse; -import org.whispersystems.textsecuregcm.entities.CreateProfileRequest; -import org.whispersystems.textsecuregcm.entities.CredentialProfileResponse; -import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse; -import org.whispersystems.textsecuregcm.entities.PniCredentialProfileResponse; -import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; -import org.whispersystems.textsecuregcm.entities.ProfileKeyCredentialProfileResponse; -import org.whispersystems.textsecuregcm.entities.UserCapabilities; -import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.s3.PolicySigner; -import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountBadge; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.ProfilesManager; -import org.whispersystems.textsecuregcm.storage.VersionedProfile; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.Util; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@Path("/v1/profile") -public class ProfileController { - - private final Logger logger = LoggerFactory.getLogger(ProfileController.class); - - private final Clock clock; - private final RateLimiters rateLimiters; - private final ProfilesManager profilesManager; - private final AccountsManager accountsManager; - private final DynamicConfigurationManager dynamicConfigurationManager; - private final ProfileBadgeConverter profileBadgeConverter; - private final Map badgeConfigurationMap; - - private final PolicySigner policySigner; - private final PostPolicyGenerator policyGenerator; - private final ServerZkProfileOperations zkProfileOperations; - - private final S3Client s3client; - private final String bucket; - - private final Executor batchIdentityCheckExecutor; - - @VisibleForTesting - static final Duration EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION = Duration.ofDays(7); - - private static final String PROFILE_KEY_CREDENTIAL_TYPE = "profileKey"; - private static final String PNI_CREDENTIAL_TYPE = "pni"; - private static final String EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE = "expiringProfileKey"; - - private static final Counter VERSION_NOT_FOUND_COUNTER = Metrics.counter(name(ProfileController.class, "versionNotFound")); - private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(ProfileController.class, "invalidAcceptLanguage"); - - public ProfileController( - Clock clock, - RateLimiters rateLimiters, - AccountsManager accountsManager, - ProfilesManager profilesManager, - DynamicConfigurationManager dynamicConfigurationManager, - ProfileBadgeConverter profileBadgeConverter, - BadgesConfiguration badgesConfiguration, - S3Client s3client, - PostPolicyGenerator policyGenerator, - PolicySigner policySigner, - String bucket, - ServerZkProfileOperations zkProfileOperations, - Executor batchIdentityCheckExecutor) { - this.clock = clock; - this.rateLimiters = rateLimiters; - this.accountsManager = accountsManager; - this.profilesManager = profilesManager; - this.dynamicConfigurationManager = dynamicConfigurationManager; - this.profileBadgeConverter = profileBadgeConverter; - this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap( - BadgeConfiguration::getId, Function.identity())); - this.zkProfileOperations = zkProfileOperations; - this.bucket = bucket; - this.s3client = s3client; - this.policyGenerator = policyGenerator; - this.policySigner = policySigner; - this.batchIdentityCheckExecutor = Preconditions.checkNotNull(batchIdentityCheckExecutor); - } - - @Timed - @PUT - @Produces(MediaType.APPLICATION_JSON) - @Consumes(MediaType.APPLICATION_JSON) - public Response setProfile(@Auth AuthenticatedAccount auth, @NotNull @Valid CreateProfileRequest request) { - - final Optional currentProfile = profilesManager.get(auth.getAccount().getUuid(), - request.getVersion()); - - if (StringUtils.isNotBlank(request.getPaymentAddress())) { - final boolean hasDisallowedPrefix = - dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream() - .anyMatch(prefix -> auth.getAccount().getNumber().startsWith(prefix)); - - if (hasDisallowedPrefix && currentProfile.map(VersionedProfile::getPaymentAddress).isEmpty()) { - return Response.status(Response.Status.FORBIDDEN).build(); - } - } - - Optional currentAvatar = Optional.empty(); - if (currentProfile.isPresent() && currentProfile.get().getAvatar() != null && currentProfile.get().getAvatar() - .startsWith("profiles/")) { - currentAvatar = Optional.of(currentProfile.get().getAvatar()); - } - - final String avatar = switch (request.getAvatarChange()) { - case UNCHANGED -> currentAvatar.orElse(null); - case CLEAR -> null; - case UPDATE -> generateAvatarObjectName(); - }; - - profilesManager.set(auth.getAccount().getUuid(), - new VersionedProfile( - request.getVersion(), - request.getName(), - avatar, - request.getAboutEmoji(), - request.getAbout(), - request.getPaymentAddress(), - request.getCommitment().serialize())); - - if (request.getAvatarChange() != CreateProfileRequest.AvatarChange.UNCHANGED) { - currentAvatar.ifPresent(s -> s3client.deleteObject(DeleteObjectRequest.builder() - .bucket(bucket) - .key(s) - .build())); - } - - final List updatedBadges = request.getBadges() - .map(badges -> mergeBadgeIdsWithExistingAccountBadges(badges, auth.getAccount().getBadges())) - .orElseGet(() -> auth.getAccount().getBadges()); - - accountsManager.update(auth.getAccount(), a -> { - a.setBadges(clock, updatedBadges); - a.setCurrentProfileVersion(request.getVersion()); - }); - - if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) { - return Response.ok(generateAvatarUploadForm(avatar)).build(); - } else { - return Response.ok().build(); - } - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/{uuid}/{version}") - public VersionedProfileResponse getProfile( - @Auth Optional auth, - @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, - @Context ContainerRequestContext containerRequestContext, - @PathParam("uuid") UUID uuid, - @PathParam("version") String version) - throws RateLimitExceededException { - - final Optional maybeRequester = auth.map(AuthenticatedAccount::getAccount); - final Account targetAccount = verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, uuid); - - return buildVersionedProfileResponse(targetAccount, - version, - isSelfProfileRequest(maybeRequester, uuid), - containerRequestContext); - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/{uuid}/{version}/{credentialRequest}") - public CredentialProfileResponse getProfile( - @Auth Optional auth, - @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, - @Context ContainerRequestContext containerRequestContext, - @PathParam("uuid") UUID uuid, - @PathParam("version") String version, - @PathParam("credentialRequest") String credentialRequest, - @QueryParam("credentialType") @DefaultValue(PROFILE_KEY_CREDENTIAL_TYPE) String credentialType) - throws RateLimitExceededException { - - final Optional maybeRequester = auth.map(AuthenticatedAccount::getAccount); - final Account targetAccount = verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, uuid); - final boolean isSelf = isSelfProfileRequest(maybeRequester, uuid); - - switch (credentialType) { - case PROFILE_KEY_CREDENTIAL_TYPE -> { - return buildProfileKeyCredentialProfileResponse(targetAccount, - version, - credentialRequest, - isSelf, - containerRequestContext); - } - - case PNI_CREDENTIAL_TYPE -> { - if (!isSelf) { - throw new ForbiddenException(); - } - - return buildPniCredentialProfileResponse(targetAccount, - version, - credentialRequest, - containerRequestContext); - } - - case EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE -> { - return buildExpiringProfileKeyCredentialProfileResponse(targetAccount, - version, - credentialRequest, - isSelf, - Instant.now().plus(EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION).truncatedTo(ChronoUnit.DAYS), - containerRequestContext); - } - - default -> throw new BadRequestException(); - } - } - - // Although clients should generally be using versioned profiles wherever possible, there are still a few lingering - // use cases for getting profiles without a version (e.g. getting a contact's unidentified access key checksum). - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/{identifier}") - public BaseProfileResponse getUnversionedProfile( - @Auth Optional auth, - @HeaderParam(OptionalAccess.UNIDENTIFIED) Optional accessKey, - @Context ContainerRequestContext containerRequestContext, - @HeaderParam(HttpHeaders.USER_AGENT) String userAgent, - @PathParam("identifier") UUID identifier, - @QueryParam("ca") boolean useCaCertificate) - throws RateLimitExceededException { - - final Optional maybeAccountByPni = accountsManager.getByPhoneNumberIdentifier(identifier); - final Optional maybeRequester = auth.map(AuthenticatedAccount::getAccount); - - final BaseProfileResponse profileResponse; - - if (maybeAccountByPni.isPresent()) { - if (maybeRequester.isEmpty()) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } else { - rateLimiters.getProfileLimiter().validate(maybeRequester.get().getUuid()); - } - - OptionalAccess.verify(maybeRequester, Optional.empty(), maybeAccountByPni); - - profileResponse = buildBaseProfileResponseForPhoneNumberIdentity(maybeAccountByPni.get()); - } else { - final Account targetAccount = verifyPermissionToReceiveAccountIdentityProfile(maybeRequester, accessKey, identifier); - - profileResponse = buildBaseProfileResponseForAccountIdentity(targetAccount, - isSelfProfileRequest(maybeRequester, identifier), - containerRequestContext); - } - - return profileResponse; - } - - @Timed - @POST - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @Path("/identity_check/batch") - public CompletableFuture runBatchIdentityCheck(@NotNull @Valid BatchIdentityCheckRequest request) { - return CompletableFuture.supplyAsync(() -> { - List responseElements = Collections.synchronizedList(new ArrayList<>()); - - final int targetBatchCount = 10; - // clamp the amount per batch to be in the closed range [30, 100] - final int batchSize = Math.min(Math.max(request.elements().size() / targetBatchCount, 30), 100); - // add 1 extra batch if there is any remainder to consume the final non-full batch - final int batchCount = - request.elements().size() / batchSize + (request.elements().size() % batchSize != 0 ? 1 : 0); - - @SuppressWarnings("rawtypes") CompletableFuture[] futures = new CompletableFuture[batchCount]; - for (int i = 0; i < batchCount; ++i) { - List batch = request.elements() - .subList(i * batchSize, Math.min((i + 1) * batchSize, request.elements().size())); - futures[i] = CompletableFuture.runAsync(() -> { - MessageDigest sha256; - try { - sha256 = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - for (final BatchIdentityCheckRequest.Element element : batch) { - checkFingerprintAndAdd(element, responseElements, sha256); - } - }, batchIdentityCheckExecutor); - } - - return Tuple.of(futures, responseElements); - }).thenCompose(tuple2 -> CompletableFuture.allOf(tuple2._1).thenApply((ignored) -> new BatchIdentityCheckResponse(tuple2._2))); - } - - private void checkFingerprintAndAdd(BatchIdentityCheckRequest.Element element, - Collection responseElements, MessageDigest md) { - - final Optional maybeAccount; - final boolean usePhoneNumberIdentity; - if (element.aci() != null) { - maybeAccount = accountsManager.getByAccountIdentifier(element.aci()); - usePhoneNumberIdentity = false; - } else { - final Optional maybeAciAccount = accountsManager.getByAccountIdentifier(element.uuid()); - - if (maybeAciAccount.isEmpty()) { - maybeAccount = accountsManager.getByPhoneNumberIdentifier(element.uuid()); - usePhoneNumberIdentity = true; - } else { - maybeAccount = maybeAciAccount; - usePhoneNumberIdentity = false; - } - } - - maybeAccount.ifPresent(account -> { - if (account.getIdentityKey() == null || account.getPhoneNumberIdentityKey() == null) { - return; - } - byte[] identityKeyBytes; - try { - identityKeyBytes = Base64.getDecoder().decode(usePhoneNumberIdentity ? account.getPhoneNumberIdentityKey() - : account.getIdentityKey()); - } catch (IllegalArgumentException ignored) { - return; - } - md.reset(); - byte[] digest = md.digest(identityKeyBytes); - byte[] fingerprint = Util.truncate(digest, 4); - - if (!Arrays.equals(fingerprint, element.fingerprint())) { - responseElements.add(new BatchIdentityCheckResponse.Element(element.aci(), element.uuid(), identityKeyBytes)); - } - }); - } - - private ProfileKeyCredentialProfileResponse buildProfileKeyCredentialProfileResponse(final Account account, - final String version, - final String encodedCredentialRequest, - final boolean isSelf, - final ContainerRequestContext containerRequestContext) { - - final ProfileKeyCredentialResponse profileKeyCredentialResponse = profilesManager.get(account.getUuid(), version) - .map(profile -> getProfileCredential(encodedCredentialRequest, profile, account.getUuid())) - .orElse(null); - - return new ProfileKeyCredentialProfileResponse( - buildVersionedProfileResponse(account, version, isSelf, containerRequestContext), - profileKeyCredentialResponse); - } - - private PniCredentialProfileResponse buildPniCredentialProfileResponse(final Account account, - final String version, - final String encodedCredentialRequest, - final ContainerRequestContext containerRequestContext) { - - final PniCredentialResponse pniCredentialResponse = profilesManager.get(account.getUuid(), version) - .map(profile -> getPniCredential(encodedCredentialRequest, profile, account.getUuid(), account.getPhoneNumberIdentifier())) - .orElse(null); - - return new PniCredentialProfileResponse( - buildVersionedProfileResponse(account, version, true, containerRequestContext), - pniCredentialResponse); - } - - private ExpiringProfileKeyCredentialProfileResponse buildExpiringProfileKeyCredentialProfileResponse( - final Account account, - final String version, - final String encodedCredentialRequest, - final boolean isSelf, - final Instant expiration, - final ContainerRequestContext containerRequestContext) { - - final ExpiringProfileKeyCredentialResponse expiringProfileKeyCredentialResponse = profilesManager.get(account.getUuid(), version) - .map(profile -> getExpiringProfileKeyCredentialResponse(encodedCredentialRequest, profile, account.getUuid(), expiration)) - .orElse(null); - - return new ExpiringProfileKeyCredentialProfileResponse( - buildVersionedProfileResponse(account, version, isSelf, containerRequestContext), - expiringProfileKeyCredentialResponse); - } - - private VersionedProfileResponse buildVersionedProfileResponse(final Account account, - final String version, - final boolean isSelf, - final ContainerRequestContext containerRequestContext) { - - final Optional maybeProfile = profilesManager.get(account.getUuid(), version); - - if (maybeProfile.isEmpty()) { - // Hypothesis: this should basically never happen since clients can't delete versions - VERSION_NOT_FOUND_COUNTER.increment(); - } - - final String name = maybeProfile.map(VersionedProfile::getName).orElse(null); - final String about = maybeProfile.map(VersionedProfile::getAbout).orElse(null); - final String aboutEmoji = maybeProfile.map(VersionedProfile::getAboutEmoji).orElse(null); - final String avatar = maybeProfile.map(VersionedProfile::getAvatar).orElse(null); - - // Allow requests where either the version matches the latest version on Account or the latest version on Account - // is empty to read the payment address. - final String paymentAddress = maybeProfile - .filter(p -> account.getCurrentProfileVersion().map(v -> v.equals(version)).orElse(true)) - .map(VersionedProfile::getPaymentAddress) - .orElse(null); - - return new VersionedProfileResponse( - buildBaseProfileResponseForAccountIdentity(account, isSelf, containerRequestContext), - name, about, aboutEmoji, avatar, paymentAddress); - } - - private BaseProfileResponse buildBaseProfileResponseForAccountIdentity(final Account account, - final boolean isSelf, - final ContainerRequestContext containerRequestContext) { - - return new BaseProfileResponse(account.getIdentityKey(), - UnidentifiedAccessChecksum.generateFor(account.getUnidentifiedAccessKey()), - account.isUnrestrictedUnidentifiedAccess(), - UserCapabilities.createForAccount(account), - profileBadgeConverter.convert( - getAcceptableLanguagesForRequest(containerRequestContext), - account.getBadges(), - isSelf), - account.getUuid()); - } - - private BaseProfileResponse buildBaseProfileResponseForPhoneNumberIdentity(final Account account) { - return new BaseProfileResponse(account.getPhoneNumberIdentityKey(), - null, - false, - UserCapabilities.createForAccount(account), - Collections.emptyList(), - account.getPhoneNumberIdentifier()); - } - - private ProfileKeyCredentialResponse getProfileCredential(final String encodedProfileCredentialRequest, - final VersionedProfile profile, - final UUID uuid) { - try { - final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment()); - final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest( - HexFormat.of().parseHex(encodedProfileCredentialRequest)); - - return zkProfileOperations.issueProfileKeyCredential(request, uuid, commitment); - } catch (IllegalArgumentException | VerificationFailedException | InvalidInputException e) { - throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); - } - } - - private PniCredentialResponse getPniCredential(final String encodedCredentialRequest, - final VersionedProfile profile, - final UUID accountIdentifier, - final UUID phoneNumberIdentifier) { - - try { - final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment()); - final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest( - HexFormat.of().parseHex(encodedCredentialRequest)); - - return zkProfileOperations.issuePniCredential(request, accountIdentifier, phoneNumberIdentifier, commitment); - } catch (IllegalArgumentException | VerificationFailedException | InvalidInputException e) { - throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); - } - } - - private ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse( - final String encodedCredentialRequest, - final VersionedProfile profile, - final UUID accountIdentifier, - final Instant expiration) { - - try { - final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.getCommitment()); - final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest( - HexFormat.of().parseHex(encodedCredentialRequest)); - - return zkProfileOperations.issueExpiringProfileKeyCredential(request, accountIdentifier, commitment, expiration); - } catch (IllegalArgumentException | VerificationFailedException | InvalidInputException e) { - throw new WebApplicationException(e, Response.status(Response.Status.BAD_REQUEST).build()); - } - } - - private ProfileAvatarUploadAttributes generateAvatarUploadForm(String objectName) { - ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); - Pair policy = policyGenerator.createFor(now, objectName, 10 * 1024 * 1024); - String signature = policySigner.getSignature(now, policy.second()); - - return new ProfileAvatarUploadAttributes(objectName, policy.first(), "private", "AWS4-HMAC-SHA256", - now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature); - - } - - private String generateAvatarObjectName() { - byte[] object = new byte[16]; - new SecureRandom().nextBytes(object); - - return "profiles/" + Base64.getUrlEncoder().encodeToString(object); - } - - private List getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) { - try { - return containerRequestContext.getAcceptableLanguages(); - } catch (final ProcessingException e) { - final String userAgent = containerRequestContext.getHeaderString(HttpHeaders.USER_AGENT); - Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment(); - logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}", - containerRequestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE), - userAgent, - e); - - return List.of(); - } - } - - private List mergeBadgeIdsWithExistingAccountBadges( - final List badgeIds, - final List accountBadges) { - LinkedHashMap existingBadges = new LinkedHashMap<>(accountBadges.size()); - for (final AccountBadge accountBadge : accountBadges) { - existingBadges.putIfAbsent(accountBadge.getId(), accountBadge); - } - - LinkedHashMap result = new LinkedHashMap<>(accountBadges.size()); - for (final String badgeId : badgeIds) { - - // duplicate in the list, ignore it - if (result.containsKey(badgeId)) { - continue; - } - - // This is for testing badges and allows them to be added to an account at any time with an expiration of 1 day - // in the future. - BadgeConfiguration badgeConfiguration = badgeConfigurationMap.get(badgeId); - if (badgeConfiguration != null && badgeConfiguration.isTestBadge()) { - result.put(badgeId, new AccountBadge(badgeId, clock.instant().plus(Duration.ofDays(1)), true)); - continue; - } - - // reordering or making visible existing badges - if (existingBadges.containsKey(badgeId)) { - AccountBadge accountBadge = existingBadges.get(badgeId).withVisibility(true); - result.put(badgeId, accountBadge); - } - } - - // take any remaining account badges and make them invisible - for (final Entry entry : existingBadges.entrySet()) { - if (!result.containsKey(entry.getKey())) { - AccountBadge accountBadge = entry.getValue().withVisibility(false); - result.put(accountBadge.getId(), accountBadge); - } - } - - return new ArrayList<>(result.values()); - } - - /** - * Verifies that the requester has permission to view the profile of the account identified by the given ACI. - * - * @param maybeRequester the authenticated account requesting the profile, if any - * @param maybeAccessKey an anonymous access key for the target account - * @param targetUuid the ACI of the target account - * - * @return the target account - * - * @throws RateLimitExceededException if the requester must wait before requesting the target account's profile - * @throws NotFoundException if no account was found for the target ACI - * @throws NotAuthorizedException if the requester is not authorized to receive the target account's profile or if the - * requester was not authenticated and did not present an anonymous access key - */ - private Account verifyPermissionToReceiveAccountIdentityProfile(final Optional maybeRequester, - final Optional maybeAccessKey, - final UUID targetUuid) throws RateLimitExceededException { - - if (maybeRequester.isEmpty() && maybeAccessKey.isEmpty()) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - if (maybeRequester.isPresent()) { - rateLimiters.getProfileLimiter().validate(maybeRequester.get().getUuid()); - } - - final Optional maybeTargetAccount = accountsManager.getByAccountIdentifier(targetUuid); - - OptionalAccess.verify(maybeRequester, maybeAccessKey, maybeTargetAccount); - assert maybeTargetAccount.isPresent(); - - return maybeTargetAccount.get(); - } - - private boolean isSelfProfileRequest(final Optional maybeRequester, final UUID targetUuid) { - return maybeRequester.map(requester -> requester.getUuid().equals(targetUuid)).orElse(false); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java deleted file mode 100644 index a941d6dbd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import java.util.Base64; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.Consumes; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.ProvisioningMessage; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.push.ProvisioningManager; -import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress; - -@Path("/v1/provisioning") -public class ProvisioningController { - - private final RateLimiters rateLimiters; - private final ProvisioningManager provisioningManager; - - public ProvisioningController(RateLimiters rateLimiters, ProvisioningManager provisioningManager) { - this.rateLimiters = rateLimiters; - this.provisioningManager = provisioningManager; - } - - @Timed - @Path("/{destination}") - @PUT - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public void sendProvisioningMessage(@Auth AuthenticatedAccount auth, - @PathParam("destination") String destinationName, - @NotNull @Valid ProvisioningMessage message) - throws RateLimitExceededException { - - rateLimiters.getMessagesLimiter().validate(auth.getAccount().getUuid()); - - if (!provisioningManager.sendProvisioningMessage(new ProvisioningAddress(destinationName, 0), - Base64.getMimeDecoder().decode(message.body()))) { - throw new WebApplicationException(Response.Status.NOT_FOUND); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RateLimitExceededException.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RateLimitExceededException.java deleted file mode 100644 index 41cf2b8ff..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RateLimitExceededException.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.controllers; - -import java.time.Duration; -import java.util.Optional; -import javax.annotation.Nullable; - -public class RateLimitExceededException extends Exception { - - @Nullable - private final Duration retryDuration; - private final boolean legacy; - - /** - * Constructs a new exception indicating when it may become safe to retry - * - * @param retryDuration A duration to wait before retrying, null if no duration can be indicated - * @param legacy whether to use a legacy status code when mapping the exception to an HTTP response - */ - public RateLimitExceededException(@Nullable final Duration retryDuration, final boolean legacy) { - super(null, null, true, false); - this.retryDuration = retryDuration; - this.legacy = legacy; - } - - public Optional getRetryDuration() { - return Optional.ofNullable(retryDuration); - } - - public boolean isLegacy() { - return legacy; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java deleted file mode 100644 index b1380b81e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.net.HttpHeaders; -import io.micrometer.core.instrument.DistributionSummary; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Optional; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.Consumes; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader; -import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; -import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; -import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; -import org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest; -import org.whispersystems.textsecuregcm.entities.RegistrationRequest; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.util.HeaderUtils; -import org.whispersystems.textsecuregcm.util.Util; - -@Path("/v1/registration") -public class RegistrationController { - - private static final Logger logger = LoggerFactory.getLogger(RegistrationController.class); - - private static final DistributionSummary REREGISTRATION_IDLE_DAYS_DISTRIBUTION = DistributionSummary - .builder(name(RegistrationController.class, "reregistrationIdleDays")) - .publishPercentiles(0.75, 0.95, 0.99, 0.999) - .distributionStatisticExpiry(Duration.ofHours(2)) - .register(Metrics.globalRegistry); - - private static final String ACCOUNT_CREATED_COUNTER_NAME = name(RegistrationController.class, "accountCreated"); - private static final String COUNTRY_CODE_TAG_NAME = "countryCode"; - private static final String REGION_CODE_TAG_NAME = "regionCode"; - private static final String VERIFICATION_TYPE_TAG_NAME = "verification"; - - private final AccountsManager accounts; - private final PhoneVerificationTokenManager phoneVerificationTokenManager; - private final RegistrationLockVerificationManager registrationLockVerificationManager; - private final RateLimiters rateLimiters; - - public RegistrationController(final AccountsManager accounts, - final PhoneVerificationTokenManager phoneVerificationTokenManager, - final RegistrationLockVerificationManager registrationLockVerificationManager, final RateLimiters rateLimiters) { - this.accounts = accounts; - this.phoneVerificationTokenManager = phoneVerificationTokenManager; - this.registrationLockVerificationManager = registrationLockVerificationManager; - this.rateLimiters = rateLimiters; - } - - @Timed - @POST - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public AccountIdentityResponse register( - @HeaderParam(HttpHeaders.AUTHORIZATION) @NotNull final BasicAuthorizationHeader authorizationHeader, - @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String signalAgent, - @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent, - @NotNull @Valid final RegistrationRequest registrationRequest) throws RateLimitExceededException, InterruptedException { - - final String number = authorizationHeader.getUsername(); - final String password = authorizationHeader.getPassword(); - - RateLimiter.adaptLegacyException(() -> rateLimiters.getRegistrationLimiter().validate(number)); - - final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(number, - registrationRequest); - - final Optional existingAccount = accounts.getByE164(number); - - existingAccount.ifPresent(account -> { - final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen()); - final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now()); - REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays()); - }); - - if (existingAccount.isPresent()) { - registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), - registrationRequest.accountAttributes().getRegistrationLock()); - } - - if (!registrationRequest.skipDeviceTransfer() && existingAccount.map(Account::isTransferSupported).orElse(false)) { - // If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user) - // before we'll let them create a new account "from scratch" - throw new WebApplicationException(Response.status(409, "device transfer available").build()); - } - - final Account account = accounts.create(number, password, signalAgent, registrationRequest.accountAttributes(), - existingAccount.map(Account::getBadges).orElseGet(ArrayList::new)); - - Metrics.counter(ACCOUNT_CREATED_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), - Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)), - Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)), - Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name()))) - .increment(); - - return new AccountIdentityResponse(account.getUuid(), - account.getNumber(), - account.getPhoneNumberIdentifier(), - account.getUsernameHash().orElse(null), - existingAccount.map(Account::isStorageSupported).orElse(false)); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java deleted file mode 100644 index 3a36838ee..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.auth.Auth; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.GET; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.signal.event.AdminEventLogger; -import org.signal.event.RemoteConfigDeleteEvent; -import org.signal.event.RemoteConfigSetEvent; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.UserRemoteConfig; -import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList; -import org.whispersystems.textsecuregcm.storage.RemoteConfig; -import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; -import org.whispersystems.textsecuregcm.util.Conversions; -import org.whispersystems.textsecuregcm.util.Util; - -@Path("/v1/config") -public class RemoteConfigController { - - private final RemoteConfigsManager remoteConfigsManager; - private final AdminEventLogger adminEventLogger; - private final List configAuthTokens; - private final Map globalConfig; - - private static final String GLOBAL_CONFIG_PREFIX = "global."; - - public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, AdminEventLogger adminEventLogger, List configAuthTokens, Map globalConfig) { - this.remoteConfigsManager = remoteConfigsManager; - this.adminEventLogger = Objects.requireNonNull(adminEventLogger); - this.configAuthTokens = configAuthTokens; - this.globalConfig = globalConfig; - } - - @Timed - @GET - @Produces(MediaType.APPLICATION_JSON) - public UserRemoteConfigList getAll(@Auth AuthenticatedAccount auth) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA1"); - - final Stream globalConfigStream = globalConfig.entrySet().stream() - .map(entry -> new UserRemoteConfig(GLOBAL_CONFIG_PREFIX + entry.getKey(), true, entry.getValue())); - return new UserRemoteConfigList(Stream.concat(remoteConfigsManager.getAll().stream().map(config -> { - final byte[] hashKey = config.getHashKey() != null ? config.getHashKey().getBytes(StandardCharsets.UTF_8) - : config.getName().getBytes(StandardCharsets.UTF_8); - boolean inBucket = isInBucket(digest, auth.getAccount().getUuid(), hashKey, config.getPercentage(), - config.getUuids()); - return new UserRemoteConfig(config.getName(), inBucket, - inBucket ? config.getValue() : config.getDefaultValue()); - }), globalConfigStream).collect(Collectors.toList())); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - } - - @Timed - @PUT - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public void set(@HeaderParam("Config-Token") String configToken, @NotNull @Valid RemoteConfig config) { - if (!isAuthorized(configToken)) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - if (config.getName().startsWith(GLOBAL_CONFIG_PREFIX)) { - throw new WebApplicationException(Response.Status.FORBIDDEN); - } - - adminEventLogger.logEvent( - new RemoteConfigSetEvent( - configToken, - config.getName(), - config.getPercentage(), - config.getDefaultValue(), - config.getValue(), - config.getHashKey(), - config.getUuids().stream().map(UUID::toString).collect(Collectors.toList()))); - remoteConfigsManager.set(config); - } - - @Timed - @DELETE - @Path("/{name}") - public void delete(@HeaderParam("Config-Token") String configToken, @PathParam("name") String name) { - if (!isAuthorized(configToken)) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } - - if (name.startsWith(GLOBAL_CONFIG_PREFIX)) { - throw new WebApplicationException(Response.Status.FORBIDDEN); - } - - adminEventLogger.logEvent(new RemoteConfigDeleteEvent(configToken, name)); - remoteConfigsManager.delete(name); - } - - @VisibleForTesting - public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage, Set uuidsInBucket) { - if (uuidsInBucket.contains(uid)) return true; - - ByteBuffer bb = ByteBuffer.wrap(new byte[16]); - bb.putLong(uid.getMostSignificantBits()); - bb.putLong(uid.getLeastSignificantBits()); - - digest.update(bb.array()); - - byte[] hash = digest.digest(hashKey); - int bucket = (int)(Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100); - - return bucket < configPercentage; - } - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private boolean isAuthorized(String configToken) { - return configToken != null && configAuthTokens.stream().anyMatch(authorized -> MessageDigest.isEqual(authorized.getBytes(), configToken.getBytes())); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java deleted file mode 100644 index 740e89514..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureBackupController.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static java.util.Objects.requireNonNull; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import java.time.Clock; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.ws.rs.Consumes; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.apache.commons.lang3.tuple.Pair; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; -import org.whispersystems.textsecuregcm.entities.AuthCheckRequest; -import org.whispersystems.textsecuregcm.entities.AuthCheckResponse; -import org.whispersystems.textsecuregcm.limits.RateLimitedByIp; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.util.UUIDUtil; - -@Path("/v1/backup") -public class SecureBackupController { - - private static final long MAX_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30); - - private final ExternalServiceCredentialsGenerator credentialsGenerator; - - private final AccountsManager accountsManager; - - public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureBackupServiceConfiguration cfg) { - return credentialsGenerator(cfg, Clock.systemUTC()); - } - - public static ExternalServiceCredentialsGenerator credentialsGenerator( - final SecureBackupServiceConfiguration cfg, - final Clock clock) { - return ExternalServiceCredentialsGenerator - .builder(cfg.getUserAuthenticationTokenSharedSecret()) - .prependUsername(true) - .withClock(clock) - .build(); - } - - public SecureBackupController( - final ExternalServiceCredentialsGenerator credentialsGenerator, - final AccountsManager accountsManager) { - this.credentialsGenerator = requireNonNull(credentialsGenerator); - this.accountsManager = requireNonNull(accountsManager); - } - - @Timed - @GET - @Path("/auth") - @Produces(MediaType.APPLICATION_JSON) - public ExternalServiceCredentials getAuth(final @Auth AuthenticatedAccount auth) { - return credentialsGenerator.generateForUuid(auth.getAccount().getUuid()); - } - - @Timed - @POST - @Path("/auth/check") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @RateLimitedByIp(RateLimiters.Handle.BACKUP_AUTH_CHECK) - public AuthCheckResponse authCheck(@NotNull @Valid final AuthCheckRequest request) { - final Map results = new HashMap<>(); - final Map> tokenToUuid = new HashMap<>(); - final Map uuidToLatestTimestamp = new HashMap<>(); - - // first pass -- filter out all tokens that contain invalid credentials - // (this could be either legit but expired or illegitimate for any reason) - request.passwords().forEach(token -> { - // each token is supposed to be in a "${username}:${password}" form, - // (note that password part may also contain ':' characters) - final String[] parts = token.split(":", 2); - if (parts.length != 2) { - results.put(token, AuthCheckResponse.Result.INVALID); - return; - } - final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]); - final Optional maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials, MAX_AGE_SECONDS); - final Optional maybeUuid = UUIDUtil.fromStringSafe(credentials.username()); - if (maybeTimestamp.isEmpty() || maybeUuid.isEmpty()) { - results.put(token, AuthCheckResponse.Result.INVALID); - return; - } - // now that we validated signature and token age, we will also find the latest of the tokens - // for each username - final Long timestamp = maybeTimestamp.get(); - final UUID uuid = maybeUuid.get(); - tokenToUuid.put(token, Pair.of(uuid, timestamp)); - final Long latestTimestamp = uuidToLatestTimestamp.getOrDefault(uuid, 0L); - if (timestamp > latestTimestamp) { - uuidToLatestTimestamp.put(uuid, timestamp); - } - }); - - // as a result of the first pass we now have some tokens that are marked invalid, - // and for others we now know if for any username the list contains multiple tokens - // we also know all distinct usernames from the list - - // if it so happens that all tokens are invalid -- respond right away - if (tokenToUuid.isEmpty()) { - return new AuthCheckResponse(results); - } - - final Predicate uuidMatches = accountsManager - .getByE164(request.number()) - .map(account -> (Predicate) candidateUuid -> account.getUuid().equals(candidateUuid)) - .orElse(candidateUuid -> false); - - // second pass will let us discard tokens that have newer versions and will also let us pick the winner (if any) - request.passwords().forEach(token -> { - if (results.containsKey(token)) { - // result already calculated - return; - } - final Pair uuidAndTime = requireNonNull(tokenToUuid.get(token)); - final Long latestTimestamp = requireNonNull(uuidToLatestTimestamp.get(uuidAndTime.getLeft())); - // check if a newer version available - if (uuidAndTime.getRight() < latestTimestamp) { - results.put(token, AuthCheckResponse.Result.INVALID); - return; - } - results.put(token, uuidMatches.test(uuidAndTime.getLeft()) - ? AuthCheckResponse.Result.MATCH - : AuthCheckResponse.Result.NO_MATCH); - }); - - return new AuthCheckResponse(results); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureStorageController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureStorageController.java deleted file mode 100644 index acbc0b024..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureStorageController.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; - -@Path("/v1/storage") -public class SecureStorageController { - - private final ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator; - - public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureStorageServiceConfiguration cfg) { - return ExternalServiceCredentialsGenerator - .builder(cfg.decodeUserAuthenticationTokenSharedSecret()) - .prependUsername(true) - .build(); - } - - public SecureStorageController(ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator) { - this.storageServiceCredentialsGenerator = storageServiceCredentialsGenerator; - } - - @Timed - @GET - @Path("/auth") - @Produces(MediaType.APPLICATION_JSON) - public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) { - return storageServiceCredentialsGenerator.generateForUuid(auth.getAccount().getUuid()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java deleted file mode 100644 index f0e181c9c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import com.codahale.metrics.annotation.Timed; -import io.dropwizard.auth.Auth; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureValueRecovery2Configuration; - -@Path("/v2/backup") -public class SecureValueRecovery2Controller { - - public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecovery2Configuration cfg) { - return ExternalServiceCredentialsGenerator - .builder(cfg.userAuthenticationTokenSharedSecret()) - .withUserDerivationKey(cfg.userIdTokenSharedSecret()) - .build(); - } - - private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator; - - public SecureValueRecovery2Controller(ExternalServiceCredentialsGenerator backupServiceCredentialGenerator) { - this.backupServiceCredentialGenerator = backupServiceCredentialGenerator; - } - - @Timed - @GET - @Path("/auth") - @Produces(MediaType.APPLICATION_JSON) - public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) { - return backupServiceCredentialGenerator.generateFor(auth.getAccount().getUuid().toString()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ServerRejectedException.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ServerRejectedException.java deleted file mode 100644 index 565d77667..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ServerRejectedException.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -public class ServerRejectedException extends Exception { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StaleDevicesException.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StaleDevicesException.java deleted file mode 100644 index 7e914f176..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StaleDevicesException.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import java.util.List; - - -public class StaleDevicesException extends Exception { - private final List staleDevices; - - public StaleDevicesException(List staleDevices) { - this.staleDevices = staleDevices; - } - - public List getStaleDevices() { - return staleDevices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StickerController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StickerController.java deleted file mode 100644 index 4b53ecbbf..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/StickerController.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import io.dropwizard.auth.Auth; -import java.security.SecureRandom; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.util.HexFormat; -import java.util.LinkedList; -import java.util.List; -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes; -import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes.StickerPackFormUploadItem; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.s3.PolicySigner; -import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.Pair; - -@Path("/v1/sticker") -public class StickerController { - - private final RateLimiters rateLimiters; - private final PolicySigner policySigner; - private final PostPolicyGenerator policyGenerator; - - public StickerController(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) { - this.rateLimiters = rateLimiters; - this.policySigner = new PolicySigner(accessSecret, region); - this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey); - } - - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("/pack/form/{count}") - public StickerPackFormUploadAttributes getStickersForm(@Auth AuthenticatedAccount auth, - @PathParam("count") @Min(1) @Max(201) int stickerCount) - throws RateLimitExceededException { - rateLimiters.getStickerPackLimiter().validate(auth.getAccount().getUuid()); - - ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); - String packId = generatePackId(); - String packLocation = "stickers/" + packId; - String manifestKey = packLocation + "/manifest.proto"; - Pair manifestPolicy = policyGenerator.createFor(now, manifestKey, - Constants.MAXIMUM_STICKER_MANIFEST_SIZE_BYTES); - String manifestSignature = policySigner.getSignature(now, manifestPolicy.second()); - StickerPackFormUploadItem manifest = new StickerPackFormUploadItem(-1, manifestKey, manifestPolicy.first(), - "private", "AWS4-HMAC-SHA256", - now.format(PostPolicyGenerator.AWS_DATE_TIME), manifestPolicy.second(), manifestSignature); - - List stickers = new LinkedList<>(); - - for (int i = 0; i < stickerCount; i++) { - String stickerKey = packLocation + "/full/" + i; - Pair stickerPolicy = policyGenerator.createFor(now, stickerKey, - Constants.MAXIMUM_STICKER_SIZE_BYTES); - String stickerSignature = policySigner.getSignature(now, stickerPolicy.second()); - stickers.add(new StickerPackFormUploadItem(i, stickerKey, stickerPolicy.first(), "private", "AWS4-HMAC-SHA256", - now.format(PostPolicyGenerator.AWS_DATE_TIME), stickerPolicy.second(), stickerSignature)); - } - - return new StickerPackFormUploadAttributes(packId, manifest, stickers); - } - - private String generatePackId() { - byte[] object = new byte[16]; - new SecureRandom().nextBytes(object); - - return HexFormat.of().formatHex(object); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java deleted file mode 100644 index 69c7eca20..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ /dev/null @@ -1,1401 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.codahale.metrics.annotation.Timed; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import com.stripe.exception.StripeException; -import io.dropwizard.auth.Auth; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import java.math.BigDecimal; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.stream.Collectors; -import javax.annotation.Nonnull; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import javax.validation.Valid; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.ClientErrorException; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.DefaultValue; -import javax.ws.rs.ForbiddenException; -import javax.ws.rs.GET; -import javax.ws.rs.InternalServerErrorException; -import javax.ws.rs.NotFoundException; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.ProcessingException; -import javax.ws.rs.Produces; -import javax.ws.rs.QueryParam; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.VerificationFailedException; -import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; -import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; -import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.badges.BadgeTranslator; -import org.whispersystems.textsecuregcm.badges.LevelTranslator; -import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; -import org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration; -import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; -import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration; -import org.whispersystems.textsecuregcm.entities.Badge; -import org.whispersystems.textsecuregcm.entities.PurchasableBadge; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; -import org.whispersystems.textsecuregcm.storage.SubscriptionManager; -import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult; -import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; -import org.whispersystems.textsecuregcm.subscriptions.PaymentMethod; -import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; -import org.whispersystems.textsecuregcm.subscriptions.StripeManager; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -@Path("/v1/subscription") -public class SubscriptionController { - - private static final Logger logger = LoggerFactory.getLogger(SubscriptionController.class); - - private final Clock clock; - private final SubscriptionConfiguration subscriptionConfiguration; - private final OneTimeDonationConfiguration oneTimeDonationConfiguration; - private final SubscriptionManager subscriptionManager; - private final StripeManager stripeManager; - private final BraintreeManager braintreeManager; - private final ServerZkReceiptOperations zkReceiptOperations; - private final IssuedReceiptsManager issuedReceiptsManager; - private final BadgeTranslator badgeTranslator; - private final LevelTranslator levelTranslator; - private final Map currencyConfiguration; - - private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = name(SubscriptionController.class, - "invalidAcceptLanguage"); - private static final String RECEIPT_ISSUED_COUNTER_NAME = name(SubscriptionController.class, "receiptIssued"); - private static final String PROCESSOR_TAG_NAME = "processor"; - private static final String TYPE_TAG_NAME = "type"; - - public SubscriptionController( - @Nonnull Clock clock, - @Nonnull SubscriptionConfiguration subscriptionConfiguration, - @Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration, - @Nonnull SubscriptionManager subscriptionManager, - @Nonnull StripeManager stripeManager, - @Nonnull BraintreeManager braintreeManager, - @Nonnull ServerZkReceiptOperations zkReceiptOperations, - @Nonnull IssuedReceiptsManager issuedReceiptsManager, - @Nonnull BadgeTranslator badgeTranslator, - @Nonnull LevelTranslator levelTranslator) { - this.clock = Objects.requireNonNull(clock); - this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration); - this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration); - this.subscriptionManager = Objects.requireNonNull(subscriptionManager); - this.stripeManager = Objects.requireNonNull(stripeManager); - this.braintreeManager = Objects.requireNonNull(braintreeManager); - this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations); - this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager); - this.badgeTranslator = Objects.requireNonNull(badgeTranslator); - this.levelTranslator = Objects.requireNonNull(levelTranslator); - - this.currencyConfiguration = buildCurrencyConfiguration(this.oneTimeDonationConfiguration, - this.subscriptionConfiguration, List.of(stripeManager, braintreeManager)); - } - - private static Map buildCurrencyConfiguration( - OneTimeDonationConfiguration oneTimeDonationConfiguration, - SubscriptionConfiguration subscriptionConfiguration, - List subscriptionProcessorManagers) { - - return oneTimeDonationConfiguration.currencies() - .entrySet().stream() - .collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> { - final String currency = currencyAndConfig.getKey(); - final OneTimeDonationCurrencyConfiguration currencyConfig = currencyAndConfig.getValue(); - - final Map> oneTimeLevelsToSuggestedAmounts = Map.of( - String.valueOf(oneTimeDonationConfiguration.boost().level()), currencyConfig.boosts(), - String.valueOf(oneTimeDonationConfiguration.gift().level()), List.of(currencyConfig.gift()) - ); - - final Map subscriptionLevelsToAmounts = subscriptionConfiguration.getLevels() - .entrySet().stream() - .filter(levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().containsKey(currency)) - .collect(Collectors.toMap( - levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()), - levelIdAndConfig -> levelIdAndConfig.getValue().getPrices().get(currency).amount())); - - final List supportedPaymentMethods = Arrays.stream(PaymentMethod.values()) - .filter(paymentMethod -> subscriptionProcessorManagers.stream() - .anyMatch(manager -> manager.getSupportedCurrencies().contains(currency) - && manager.supportsPaymentMethod(paymentMethod))) - .map(PaymentMethod::name) - .collect(Collectors.toList()); - - if (supportedPaymentMethods.isEmpty()) { - throw new RuntimeException("Configuration has currency with no processor support: " + currency); - } - - return new CurrencyConfiguration(currencyConfig.minimum(), oneTimeLevelsToSuggestedAmounts, - subscriptionLevelsToAmounts, supportedPaymentMethods); - })); - } - - @VisibleForTesting - GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(List acceptableLanguages) { - - final Map levels = new HashMap<>(); - - subscriptionConfiguration.getLevels().forEach((levelId, levelConfig) -> { - final LevelConfiguration levelConfiguration = new LevelConfiguration( - levelTranslator.translate(acceptableLanguages, levelConfig.getBadge()), - badgeTranslator.translate(acceptableLanguages, levelConfig.getBadge())); - levels.put(String.valueOf(levelId), levelConfiguration); - }); - - final Badge boostBadge = badgeTranslator.translate(acceptableLanguages, - oneTimeDonationConfiguration.boost().badge()); - levels.put(String.valueOf(oneTimeDonationConfiguration.boost().level()), - new LevelConfiguration( - boostBadge.getName(), - // NB: the one-time badges are PurchasableBadge, which has a `duration` field - new PurchasableBadge( - boostBadge, - oneTimeDonationConfiguration.boost().expiration()))); - - final Badge giftBadge = badgeTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.gift().badge()); - levels.put(String.valueOf(oneTimeDonationConfiguration.gift().level()), - new LevelConfiguration( - giftBadge.getName(), - new PurchasableBadge( - giftBadge, - oneTimeDonationConfiguration.gift().expiration()))); - - return new GetSubscriptionConfigurationResponse(currencyConfiguration, levels); - } - - @Timed - @DELETE - @Path("/{subscriberId}") - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture deleteSubscriber( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId) { - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenCompose(getResult -> { - if (getResult == GetResult.NOT_STORED || getResult == GetResult.PASSWORD_MISMATCH) { - throw new NotFoundException(); - } - return getResult.record.getProcessorCustomer() - .map(processorCustomer -> getManagerForProcessor(processorCustomer.processor()).cancelAllActiveSubscriptions(processorCustomer.customerId())) - // a missing customer ID is OK; it means the subscriber never started to add a payment method - .orElseGet(() -> CompletableFuture.completedFuture(null)); - }) - .thenCompose(unused -> subscriptionManager.canceledAt(requestData.subscriberUser, requestData.now)) - .thenApply(unused -> Response.ok().build()); - } - - @Timed - @PUT - @Path("/{subscriberId}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture updateSubscriber( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId) { - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenCompose(getResult -> { - if (getResult == GetResult.PASSWORD_MISMATCH) { - throw new ForbiddenException("subscriberId mismatch"); - } else if (getResult == GetResult.NOT_STORED) { - // create a customer and write it to ddb - return subscriptionManager.create(requestData.subscriberUser, requestData.hmac, requestData.now) - .thenApply(updatedRecord -> { - if (updatedRecord == null) { - throw new ForbiddenException(); - } - return updatedRecord; - }); - } else { - // already exists so just touch access time and return - return subscriptionManager.accessedAt(requestData.subscriberUser, requestData.now) - .thenApply(unused -> getResult.record); - } - }) - .thenApply(record -> Response.ok().build()); - } - - record CreatePaymentMethodResponse(String clientSecret, SubscriptionProcessor processor) { - - } - - @Timed - @POST - @Path("/{subscriberId}/create_payment_method") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createPaymentMethod( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId, - @QueryParam("type") @DefaultValue("CARD") PaymentMethod paymentMethodType) { - - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - - final SubscriptionProcessorManager subscriptionProcessorManager = getManagerForPaymentMethod(paymentMethodType); - - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> { - final CompletableFuture updatedRecordFuture = - record.getProcessorCustomer() - .map(ProcessorCustomer::processor) - .map(processor -> { - if (processor != subscriptionProcessorManager.getProcessor()) { - throw new ClientErrorException("existing processor does not match", Status.CONFLICT); - } - - return CompletableFuture.completedFuture(record); - }) - .orElseGet(() -> subscriptionProcessorManager.createCustomer(requestData.subscriberUser) - .thenApply(ProcessorCustomer::customerId) - .thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record, - new ProcessorCustomer(customerId, subscriptionProcessorManager.getProcessor()), - Instant.now()))); - - return updatedRecordFuture.thenCompose( - updatedRecord -> { - final String customerId = updatedRecord.getProcessorCustomer() - .filter(pc -> pc.processor().equals(subscriptionProcessorManager.getProcessor())) - .orElseThrow(() -> new InternalServerErrorException("record should not be missing customer")) - .customerId(); - return subscriptionProcessorManager.createPaymentMethodSetupToken(customerId); - }); - }) - .thenApply( - token -> Response.ok(new CreatePaymentMethodResponse(token, subscriptionProcessorManager.getProcessor())) - .build()); - } - - public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) { - - } - - public record CreatePayPalBillingAgreementResponse(@NotBlank String approvalUrl, @NotBlank String token) { - - } - - @Timed - @POST - @Path("/{subscriberId}/create_payment_method/paypal") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createPayPalPaymentMethod( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId, - @NotNull @Valid CreatePayPalBillingAgreementRequest request, - @Context ContainerRequestContext containerRequestContext) { - - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> { - - final CompletableFuture updatedRecordFuture = - record.getProcessorCustomer() - .map(ProcessorCustomer::processor) - .map(processor -> { - if (processor != braintreeManager.getProcessor()) { - throw new ClientErrorException("existing processor does not match", Status.CONFLICT); - } - return CompletableFuture.completedFuture(record); - }) - .orElseGet(() -> braintreeManager.createCustomer(requestData.subscriberUser) - .thenApply(ProcessorCustomer::customerId) - .thenCompose(customerId -> subscriptionManager.setProcessorAndCustomerId(record, - new ProcessorCustomer(customerId, braintreeManager.getProcessor()), - Instant.now()))); - - return updatedRecordFuture.thenCompose( - updatedRecord -> { - final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream() - .filter(l -> !"*".equals(l.getLanguage())) - .findFirst() - .orElse(Locale.US); - - return braintreeManager.createPayPalBillingAgreement(request.returnUrl, request.cancelUrl, - locale.toLanguageTag()); - }); - }) - .thenApply( - billingAgreementApprovalDetails -> Response.ok( - new CreatePayPalBillingAgreementResponse(billingAgreementApprovalDetails.approvalUrl(), - billingAgreementApprovalDetails.billingAgreementToken())) - .build()); - } - - private SubscriptionProcessorManager getManagerForPaymentMethod(PaymentMethod paymentMethod) { - return switch (paymentMethod) { - case CARD -> stripeManager; - case PAYPAL -> braintreeManager; - }; - } - - private SubscriptionProcessorManager getManagerForProcessor(SubscriptionProcessor processor) { - return switch (processor) { - case STRIPE -> stripeManager; - case BRAINTREE -> braintreeManager; - }; - } - - @Timed - @POST - @Path("/{subscriberId}/default_payment_method/{paymentMethodId}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @Deprecated // use /{subscriberId}/default_payment_method/{processor}/{paymentMethodId} - public CompletableFuture setDefaultPaymentMethod( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId, - @PathParam("paymentMethodId") @NotEmpty String paymentMethodId) { - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> stripeManager.setDefaultPaymentMethodForCustomer( - record.getProcessorCustomer().orElseThrow().customerId(), paymentMethodId, record.subscriptionId)) - .thenApply(customer -> Response.ok().build()); - } - - @Timed - @POST - @Path("/{subscriberId}/default_payment_method/{processor}/{paymentMethodToken}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture setDefaultPaymentMethodWithProcessor( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId, - @PathParam("processor") SubscriptionProcessor processor, - @PathParam("paymentMethodToken") @NotEmpty String paymentMethodToken) { - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - - final SubscriptionProcessorManager manager = getManagerForProcessor(processor); - - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> record.getProcessorCustomer() - .map(processorCustomer -> manager.setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), - paymentMethodToken, record.subscriptionId)) - .orElseThrow(() -> - // a missing customer ID indicates the client made requests out of order, - // and needs to call create_payment_method to create a customer for the given payment method - new ClientErrorException(Status.CONFLICT))) - .thenApply(customer -> Response.ok().build()); - } - public static class SetSubscriptionLevelSuccessResponse { - - private final long level; - - @JsonCreator - public SetSubscriptionLevelSuccessResponse( - @JsonProperty("level") long level) { - this.level = level; - } - - public long getLevel() { - return level; - } - } - - public static class SetSubscriptionLevelErrorResponse { - - public static class Error { - - public enum Type { - UNSUPPORTED_LEVEL, - UNSUPPORTED_CURRENCY, - PAYMENT_REQUIRES_ACTION, - } - - private final Type type; - private final String message; - - @JsonCreator - public Error( - @JsonProperty("type") Type type, - @JsonProperty("message") String message) { - this.type = type; - this.message = message; - } - - public Type getType() { - return type; - } - - public String getMessage() { - return message; - } - } - - private final List errors; - - @JsonCreator - public SetSubscriptionLevelErrorResponse( - @JsonProperty("errors") List errors) { - this.errors = errors; - } - - public List getErrors() { - return errors; - } - } - - @Timed - @PUT - @Path("/{subscriberId}/level/{level}/{currency}/{idempotencyKey}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture setSubscriptionLevel( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId, - @PathParam("level") long level, - @PathParam("currency") String currency, - @PathParam("idempotencyKey") String idempotencyKey) { - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> { - - final ProcessorCustomer processorCustomer = record.getProcessorCustomer() - .orElseThrow(() -> - // a missing customer ID indicates the client made requests out of order, - // and needs to call create_payment_method to create a customer for the given payment method - new ClientErrorException(Status.CONFLICT)); - - final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency, - processorCustomer.processor()); - - final SubscriptionProcessorManager manager = getManagerForProcessor(processorCustomer.processor()); - - return Optional.ofNullable(record.subscriptionId).map(subId -> { - // we already have a subscription in our records so let's check the level and currency, - // and only change it if needed - return manager.getSubscription(subId).thenCompose( - subscription -> manager.getLevelAndCurrencyForSubscription(subscription) - .thenCompose(existingLevelAndCurrency -> { - if (existingLevelAndCurrency.equals(new SubscriptionProcessorManager.LevelAndCurrency(level, - currency.toLowerCase(Locale.ROOT)))) { - return CompletableFuture.completedFuture(subscription); - } - return manager.updateSubscription( - subscription, subscriptionTemplateId, level, idempotencyKey) - .thenCompose(updatedSubscription -> - subscriptionManager.subscriptionLevelChanged(requestData.subscriberUser, - requestData.now, - level, updatedSubscription.id()) - .thenApply(unused -> updatedSubscription)); - })); - }).orElseGet(() -> { - long lastSubscriptionCreatedAt = - record.subscriptionCreatedAt != null ? record.subscriptionCreatedAt.getEpochSecond() : 0; - - // we don't have a subscription yet so create it and then record the subscription id - return manager.createSubscription(processorCustomer.customerId(), - subscriptionTemplateId, - level, - lastSubscriptionCreatedAt) - .exceptionally(e -> { - if (e.getCause() instanceof StripeException stripeException - && stripeException.getCode().equals("subscription_payment_intent_requires_action")) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(new SetSubscriptionLevelErrorResponse(List.of( - new SetSubscriptionLevelErrorResponse.Error( - SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null - ) - ))).build()); - } - if (e instanceof RuntimeException re) { - throw re; - } - - throw new CompletionException(e); - }) - .thenCompose(subscription -> subscriptionManager.subscriptionCreated( - requestData.subscriberUser, subscription.id(), requestData.now, level) - .thenApply(unused -> subscription)); - }); - }) - .thenApply(unused -> Response.ok(new SetSubscriptionLevelSuccessResponse(level)).build()); - } - - public static class GetLevelsResponse { - - public static class Level { - - private final String name; - private final Badge badge; - private final Map currencies; - - @JsonCreator - public Level( - @JsonProperty("name") String name, - @JsonProperty("badge") Badge badge, - @JsonProperty("currencies") Map currencies) { - this.name = name; - this.badge = badge; - this.currencies = currencies; - } - - public String getName() { - return name; - } - - public Badge getBadge() { - return badge; - } - - public Map getCurrencies() { - return currencies; - } - } - - private final Map levels; - - @JsonCreator - public GetLevelsResponse( - @JsonProperty("levels") Map levels) { - this.levels = levels; - } - - public Map getLevels() { - return levels; - } - } - - /** - * Comprehensive configuration for subscriptions and one-time donations - * - * @param currencies map of lower-cased ISO 3 currency codes to minimums and level-specific scalar amounts - * @param levels map of numeric level IDs to level-specific configuration - */ - public record GetSubscriptionConfigurationResponse(Map currencies, - Map levels) { - - } - - /** - * Configuration for a currency - use to present appropriate client interfaces - * - * @param minimum the minimum amount that may be submitted for a one-time donation in the currency - * @param oneTime map of numeric one-time donation level IDs to the list of default amounts to be - * presented - * @param subscription map of numeric subscription level IDs to the amount charged for that level - * @param supportedPaymentMethods the payment methods that support the given currency - */ - public record CurrencyConfiguration(BigDecimal minimum, Map> oneTime, - Map subscription, - List supportedPaymentMethods) { - - } - - /** - * Configuration for a donation level - use to present appropriate client interfaces - * - * @param name the localized name for the level - * @param badge the displayable badge associated with the level - */ - public record LevelConfiguration(String name, Badge badge) { - - } - - @Timed - @GET - @Path("/configuration") - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture getConfiguration(@Context ContainerRequestContext containerRequestContext) { - return CompletableFuture.supplyAsync(() -> { - List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); - return Response.ok(buildGetSubscriptionConfigurationResponse(acceptableLanguages)).build(); - }); - } - - @Timed - @GET - @Path("/levels") - @Produces(MediaType.APPLICATION_JSON) - @Deprecated // use /configuration - public CompletableFuture getLevels(@Context ContainerRequestContext containerRequestContext) { - return CompletableFuture.supplyAsync(() -> { - List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); - GetLevelsResponse getLevelsResponse = new GetLevelsResponse( - subscriptionConfiguration.getLevels().entrySet().stream().collect(Collectors.toMap(Entry::getKey, - entry -> new GetLevelsResponse.Level( - levelTranslator.translate(acceptableLanguages, entry.getValue().getBadge()), - badgeTranslator.translate(acceptableLanguages, entry.getValue().getBadge()), - entry.getValue().getPrices().entrySet().stream().collect( - Collectors.toMap(levelEntry -> levelEntry.getKey().toUpperCase(Locale.ROOT), - levelEntry -> levelEntry.getValue().amount())))))); - return Response.ok(getLevelsResponse).build(); - }); - } - - public static class GetBoostBadgesResponse { - public static class Level { - private final PurchasableBadge badge; - - @JsonCreator - public Level( - @JsonProperty("badge") PurchasableBadge badge) { - this.badge = badge; - } - - public PurchasableBadge getBadge() { - return badge; - } - } - - private final Map levels; - - @JsonCreator - public GetBoostBadgesResponse( - @JsonProperty("levels") Map levels) { - this.levels = Objects.requireNonNull(levels); - } - - public Map getLevels() { - return levels; - } - } - - @Timed - @GET - @Path("/boost/badges") - @Produces(MediaType.APPLICATION_JSON) - @Deprecated // use /configuration - public CompletableFuture getBoostBadges(@Context ContainerRequestContext containerRequestContext) { - return CompletableFuture.supplyAsync(() -> { - long boostLevel = oneTimeDonationConfiguration.boost().level(); - String boostBadge = oneTimeDonationConfiguration.boost().badge(); - long giftLevel = oneTimeDonationConfiguration.gift().level(); - String giftBadge = oneTimeDonationConfiguration.gift().badge(); - List acceptableLanguages = getAcceptableLanguagesForRequest(containerRequestContext); - GetBoostBadgesResponse getBoostBadgesResponse = new GetBoostBadgesResponse(Map.of( - boostLevel, new GetBoostBadgesResponse.Level( - new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, boostBadge), - oneTimeDonationConfiguration.boost().expiration())), - giftLevel, new GetBoostBadgesResponse.Level( - new PurchasableBadge(badgeTranslator.translate(acceptableLanguages, giftBadge), - oneTimeDonationConfiguration.gift().expiration())))); - return Response.ok(getBoostBadgesResponse).build(); - }); - } - - @Timed - @GET - @Path("/boost/amounts") - @Produces(MediaType.APPLICATION_JSON) - @Deprecated // use /configuration - public CompletableFuture getBoostAmounts() { - return CompletableFuture.supplyAsync(() -> Response.ok( - oneTimeDonationConfiguration.currencies().entrySet().stream().collect( - Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), entry -> entry.getValue().boosts()))) - .build()); - } - - @Timed - @GET - @Path("/boost/amounts/gift") - @Produces(MediaType.APPLICATION_JSON) - @Deprecated // use /configuration - public CompletableFuture getGiftAmounts() { - return CompletableFuture.supplyAsync(() -> Response.ok( - oneTimeDonationConfiguration.currencies().entrySet().stream().collect( - Collectors.toMap(entry -> entry.getKey().toUpperCase(Locale.ROOT), entry -> entry.getValue().gift()))) - .build()); - } - - public static class CreateBoostRequest { - - @NotEmpty - @ExactlySize(3) - public String currency; - @Min(1) - public long amount; - public Long level; - } - - public static class CreatePayPalBoostRequest extends CreateBoostRequest { - - @NotEmpty - public String returnUrl; - @NotEmpty - public String cancelUrl; - } - - record CreatePayPalBoostResponse(String approvalUrl, String paymentId) { - - } - - public static class CreateBoostResponse { - - private final String clientSecret; - - @JsonCreator - public CreateBoostResponse( - @JsonProperty("clientSecret") String clientSecret) { - this.clientSecret = clientSecret; - } - - public String getClientSecret() { - return clientSecret; - } - } - - /** - * Creates a Stripe PaymentIntent with the requested amount and currency - */ - @Timed - @POST - @Path("/boost/create") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createBoostPaymentIntent(@NotNull @Valid CreateBoostRequest request) { - return CompletableFuture.runAsync(() -> { - if (request.level == null) { - request.level = oneTimeDonationConfiguration.boost().level(); - } - BigDecimal amount = BigDecimal.valueOf(request.amount); - if (request.level == oneTimeDonationConfiguration.gift().level()) { - BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies() - .get(request.currency.toLowerCase(Locale.ROOT)).gift(); - if (amountConfigured == null || - SubscriptionCurrencyUtil.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured) - .compareTo(amount) != 0) { - throw new WebApplicationException( - Response.status(Status.CONFLICT).entity(Map.of("error", "level_amount_mismatch")).build()); - } - } - validateRequestCurrencyAmount(request, amount, stripeManager); - }) - .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level)) - .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build()); - } - - /** - * Validates that the currency and amount in the request are supported by the {@code manager} and exceed the minimum - * permitted amount - * - * @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details - */ - private void validateRequestCurrencyAmount(CreateBoostRequest request, BigDecimal amount, - SubscriptionProcessorManager manager) { - - if (!manager.supportsCurrency(request.currency.toLowerCase(Locale.ROOT))) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(Map.of("error", "unsupported_currency")).build()); - } - - BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies() - .get(request.currency.toLowerCase(Locale.ROOT)).minimum(); - BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount( - request.currency, - minCurrencyAmountMajorUnits); - if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(Map.of( - "error", "amount_below_currency_minimum", - "minimum", minCurrencyAmountMajorUnits.toString())).build()); - } - } - - @Timed - @POST - @Path("/boost/paypal/create") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createPayPalBoost(@NotNull @Valid CreatePayPalBoostRequest request, - @Context ContainerRequestContext containerRequestContext) { - - return CompletableFuture.runAsync(() -> { - if (request.level == null) { - request.level = oneTimeDonationConfiguration.boost().level(); - } - - validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), braintreeManager); - }) - .thenCompose(unused -> { - final Locale locale = getAcceptableLanguagesForRequest(containerRequestContext).stream() - .filter(l -> !"*".equals(l.getLanguage())) - .findFirst() - .orElse(Locale.US); - - return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount, - locale.toLanguageTag(), - request.returnUrl, request.cancelUrl); - }) - .thenApply(approvalDetails -> Response.ok( - new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build()); - } - - public static class ConfirmPayPalBoostRequest extends CreateBoostRequest { - - @NotEmpty - public String payerId; - @NotEmpty - public String paymentId; // PAYID-… - @NotEmpty - public String paymentToken; // EC-… - } - - record ConfirmPayPalBoostResponse(String paymentId) { - - } - - @Timed - @POST - @Path("/boost/paypal/confirm") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture confirmPayPalBoost(@NotNull @Valid ConfirmPayPalBoostRequest request) { - - return CompletableFuture.runAsync(() -> { - if (request.level == null) { - request.level = oneTimeDonationConfiguration.boost().level(); - } - }) - .thenCompose(unused -> braintreeManager.captureOneTimePayment(request.payerId, request.paymentId, - request.paymentToken, request.currency, request.amount, request.level)) - .thenApply(chargeSuccessDetails -> Response.ok( - new ConfirmPayPalBoostResponse(chargeSuccessDetails.paymentId())).build()); - } - - public static class CreateBoostReceiptCredentialsRequest { - - /** - * a payment ID from {@link #processor} - */ - @NotNull - public String paymentIntentId; - @NotNull - public byte[] receiptCredentialRequest; - - @NotNull - public SubscriptionProcessor processor = SubscriptionProcessor.STRIPE; - } - - public static class CreateBoostReceiptCredentialsResponse { - - private final byte[] receiptCredentialResponse; - - @JsonCreator - public CreateBoostReceiptCredentialsResponse( - @JsonProperty("receiptCredentialResponse") byte[] receiptCredentialResponse) { - this.receiptCredentialResponse = receiptCredentialResponse; - } - - public byte[] getReceiptCredentialResponse() { - return receiptCredentialResponse; - } - } - - @Timed - @POST - @Path("/boost/receipt_credentials") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createBoostReceiptCredentials(@NotNull @Valid CreateBoostReceiptCredentialsRequest request) { - - final SubscriptionProcessorManager manager = getManagerForProcessor(request.processor); - - return manager.getPaymentDetails(request.paymentIntentId) - .thenCompose(paymentDetails -> { - if (paymentDetails == null) { - throw new WebApplicationException(Status.NOT_FOUND); - } - switch (paymentDetails.status()) { - case PROCESSING -> throw new WebApplicationException(Status.NO_CONTENT); - case SUCCEEDED -> { - } - default -> throw new WebApplicationException(Status.PAYMENT_REQUIRED); - } - - long level = oneTimeDonationConfiguration.boost().level(); - if (paymentDetails.customMetadata() != null) { - String levelMetadata = paymentDetails.customMetadata() - .getOrDefault("level", Long.toString(oneTimeDonationConfiguration.boost().level())); - try { - level = Long.parseLong(levelMetadata); - } catch (NumberFormatException e) { - logger.error("failed to parse level metadata ({}) on payment intent {}", levelMetadata, - paymentDetails.id(), e); - throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR); - } - } - Duration levelExpiration; - if (oneTimeDonationConfiguration.boost().level() == level) { - levelExpiration = oneTimeDonationConfiguration.boost().expiration(); - } else if (oneTimeDonationConfiguration.gift().level() == level) { - levelExpiration = oneTimeDonationConfiguration.gift().expiration(); - } else { - logger.error("level ({}) returned from payment intent that is unknown to the server", level); - throw new WebApplicationException(Status.INTERNAL_SERVER_ERROR); - } - ReceiptCredentialRequest receiptCredentialRequest; - try { - receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest); - } catch (InvalidInputException e) { - throw new BadRequestException("invalid receipt credential request", e); - } - final long finalLevel = level; - return issuedReceiptsManager.recordIssuance(paymentDetails.id(), manager.getProcessor(), - receiptCredentialRequest, clock.instant()) - .thenApply(unused -> { - Instant expiration = paymentDetails.created() - .plus(levelExpiration) - .truncatedTo(ChronoUnit.DAYS) - .plus(1, ChronoUnit.DAYS); - ReceiptCredentialResponse receiptCredentialResponse; - try { - receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( - receiptCredentialRequest, expiration.getEpochSecond(), finalLevel); - } catch (VerificationFailedException e) { - throw new BadRequestException("receipt credential request failed verification", e); - } - Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME, - PROCESSOR_TAG_NAME, manager.getProcessor().toString(), - TYPE_TAG_NAME, "boost") - .increment(); - return Response.ok(new CreateBoostReceiptCredentialsResponse(receiptCredentialResponse.serialize())) - .build(); - }); - }); - } - - public static class GetSubscriptionInformationResponse { - - public static class Subscription { - - private final long level; - private final Instant billingCycleAnchor; - private final Instant endOfCurrentPeriod; - private final boolean active; - private final boolean cancelAtPeriodEnd; - private final String currency; - private final BigDecimal amount; - private final String status; - private final SubscriptionProcessor processor; - - @JsonCreator - public Subscription( - @JsonProperty("level") long level, - @JsonProperty("billingCycleAnchor") Instant billingCycleAnchor, - @JsonProperty("endOfCurrentPeriod") Instant endOfCurrentPeriod, - @JsonProperty("active") boolean active, - @JsonProperty("cancelAtPeriodEnd") boolean cancelAtPeriodEnd, - @JsonProperty("currency") String currency, - @JsonProperty("amount") BigDecimal amount, - @JsonProperty("status") String status, - @JsonProperty("processor") SubscriptionProcessor processor) { - this.level = level; - this.billingCycleAnchor = billingCycleAnchor; - this.endOfCurrentPeriod = endOfCurrentPeriod; - this.active = active; - this.cancelAtPeriodEnd = cancelAtPeriodEnd; - this.currency = currency; - this.amount = amount; - this.status = status; - this.processor = processor; - } - - public long getLevel() { - return level; - } - - public Instant getBillingCycleAnchor() { - return billingCycleAnchor; - } - - public Instant getEndOfCurrentPeriod() { - return endOfCurrentPeriod; - } - - public boolean isActive() { - return active; - } - - public boolean isCancelAtPeriodEnd() { - return cancelAtPeriodEnd; - } - - public String getCurrency() { - return currency; - } - - public BigDecimal getAmount() { - return amount; - } - - public String getStatus() { - return status; - } - - public SubscriptionProcessor getProcessor() { - return processor; - } - } - - public static class ChargeFailure { - private final String code; - private final String message; - private final String outcomeNetworkStatus; - private final String outcomeReason; - private final String outcomeType; - - @JsonCreator - public ChargeFailure( - @JsonProperty("code") String code, - @JsonProperty("message") String message, - @JsonProperty("outcomeNetworkStatus") String outcomeNetworkStatus, - @JsonProperty("outcomeReason") String outcomeReason, - @JsonProperty("outcomeType") String outcomeType) { - this.code = code; - this.message = message; - this.outcomeNetworkStatus = outcomeNetworkStatus; - this.outcomeReason = outcomeReason; - this.outcomeType = outcomeType; - } - - public String getCode() { - return code; - } - - public String getMessage() { - return message; - } - - public String getOutcomeNetworkStatus() { - return outcomeNetworkStatus; - } - - public String getOutcomeReason() { - return outcomeReason; - } - - public String getOutcomeType() { - return outcomeType; - } - } - - private final Subscription subscription; - private final ChargeFailure chargeFailure; - - @JsonCreator - public GetSubscriptionInformationResponse( - @JsonProperty("subscription") Subscription subscription, - @JsonProperty("chargeFailure") ChargeFailure chargeFailure) { - this.subscription = subscription; - this.chargeFailure = chargeFailure; - } - - public Subscription getSubscription() { - return subscription; - } - - @JsonInclude(Include.NON_NULL) - public ChargeFailure getChargeFailure() { - return chargeFailure; - } - } - - @Timed - @GET - @Path("/{subscriberId}") - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture getSubscriptionInformation( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId) { - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> { - if (record.subscriptionId == null) { - return CompletableFuture.completedFuture(Response.ok(new GetSubscriptionInformationResponse(null, null)).build()); - } - - final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); - - return manager.getSubscription(record.subscriptionId).thenCompose(subscription -> - manager.getSubscriptionInformation(subscription).thenApply(subscriptionInformation -> { - final GetSubscriptionInformationResponse.ChargeFailure chargeFailure = Optional.ofNullable(subscriptionInformation.chargeFailure()) - .map(chargeFailure1 -> new GetSubscriptionInformationResponse.ChargeFailure( - subscriptionInformation.chargeFailure().code(), - subscriptionInformation.chargeFailure().message(), - subscriptionInformation.chargeFailure().outcomeNetworkStatus(), - subscriptionInformation.chargeFailure().outcomeReason(), - subscriptionInformation.chargeFailure().outcomeType() - )) - .orElse(null); - return Response.ok( - new GetSubscriptionInformationResponse( - new GetSubscriptionInformationResponse.Subscription( - subscriptionInformation.level(), - subscriptionInformation.billingCycleAnchor(), - subscriptionInformation.endOfCurrentPeriod(), - subscriptionInformation.active(), - subscriptionInformation.cancelAtPeriodEnd(), - subscriptionInformation.price().currency(), - subscriptionInformation.price().amount(), - subscriptionInformation.status().getApiValue(), - manager.getProcessor()), - chargeFailure - )).build(); - })); - }); - } - - public static class GetReceiptCredentialsRequest { - - private final byte[] receiptCredentialRequest; - - @JsonCreator - public GetReceiptCredentialsRequest( - @JsonProperty("receiptCredentialRequest") byte[] receiptCredentialRequest) { - this.receiptCredentialRequest = receiptCredentialRequest; - } - - @NotEmpty - public byte[] getReceiptCredentialRequest() { - return receiptCredentialRequest; - } - } - - public static class GetReceiptCredentialsResponse { - - private final byte[] receiptCredentialResponse; - - @JsonCreator - public GetReceiptCredentialsResponse( - @JsonProperty("receiptCredentialResponse") byte[] receiptCredentialResponse) { - this.receiptCredentialResponse = receiptCredentialResponse; - } - - @NotEmpty - public byte[] getReceiptCredentialResponse() { - return receiptCredentialResponse; - } - } - - @Timed - @POST - @Path("/{subscriberId}/receipt_credentials") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public CompletableFuture createSubscriptionReceiptCredentials( - @Auth Optional authenticatedAccount, - @PathParam("subscriberId") String subscriberId, - @NotNull @Valid GetReceiptCredentialsRequest request) { - RequestData requestData = RequestData.process(authenticatedAccount, subscriberId, clock); - return subscriptionManager.get(requestData.subscriberUser, requestData.hmac) - .thenApply(this::requireRecordFromGetResult) - .thenCompose(record -> { - if (record.subscriptionId == null) { - return CompletableFuture.completedFuture(Response.status(Status.NOT_FOUND).build()); - } - ReceiptCredentialRequest receiptCredentialRequest; - try { - receiptCredentialRequest = new ReceiptCredentialRequest(request.getReceiptCredentialRequest()); - } catch (InvalidInputException e) { - throw new BadRequestException("invalid receipt credential request", e); - } - - final SubscriptionProcessorManager manager = getManagerForProcessor(record.getProcessorCustomer().orElseThrow().processor()); - return manager.getReceiptItem(record.subscriptionId) - .thenCompose(receipt -> issuedReceiptsManager.recordIssuance( - receipt.itemId(), manager.getProcessor(), receiptCredentialRequest, - requestData.now) - .thenApply(unused -> receipt)) - .thenApply(receipt -> { - ReceiptCredentialResponse receiptCredentialResponse; - try { - receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential( - receiptCredentialRequest, - receiptExpirationWithGracePeriod(receipt.expiration()).getEpochSecond(), receipt.level()); - } catch (VerificationFailedException e) { - throw new BadRequestException("receipt credential request failed verification", e); - } - Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME, - PROCESSOR_TAG_NAME, manager.getProcessor().toString(), - TYPE_TAG_NAME, "subscription") - .increment(); - return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())) - .build(); - }); - }); - } - - private Instant receiptExpirationWithGracePeriod(Instant itemExpiration) { - return itemExpiration.plus(subscriptionConfiguration.getBadgeGracePeriod()) - .truncatedTo(ChronoUnit.DAYS) - .plus(1, ChronoUnit.DAYS); - } - - private String getSubscriptionTemplateId(long level, String currency, SubscriptionProcessor processor) { - SubscriptionLevelConfiguration levelConfiguration = subscriptionConfiguration.getLevels().get(level); - if (levelConfiguration == null) { - throw new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(new SetSubscriptionLevelErrorResponse(List.of( - new SetSubscriptionLevelErrorResponse.Error( - SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null)))) - .build()); - } - - return Optional.ofNullable(levelConfiguration.getPrices() - .get(currency.toLowerCase(Locale.ROOT))) - .map(priceConfiguration -> priceConfiguration.processorIds().get(processor)) - .orElseThrow(() -> new BadRequestException(Response.status(Status.BAD_REQUEST) - .entity(new SetSubscriptionLevelErrorResponse(List.of( - new SetSubscriptionLevelErrorResponse.Error( - SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null)))) - .build())); - } - - private SubscriptionManager.Record requireRecordFromGetResult(SubscriptionManager.GetResult getResult) { - if (getResult == GetResult.PASSWORD_MISMATCH) { - throw new ForbiddenException("subscriberId mismatch"); - } else if (getResult == GetResult.NOT_STORED) { - throw new NotFoundException(); - } else { - return getResult.record; - } - } - - private List getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) { - try { - return containerRequestContext.getAcceptableLanguages(); - } catch (final ProcessingException e) { - final String userAgent = containerRequestContext.getHeaderString(HttpHeaders.USER_AGENT); - Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment(); - logger.debug("Could not get acceptable languages; Accept-Language: {}; User-Agent: {}", - containerRequestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE), - userAgent, - e); - - return List.of(); - } - } - - private static class RequestData { - - public final byte[] subscriberBytes; - public final byte[] subscriberUser; - public final byte[] subscriberKey; - public final byte[] hmac; - public final Instant now; - - private RequestData( - @Nonnull byte[] subscriberBytes, - @Nonnull byte[] subscriberUser, - @Nonnull byte[] subscriberKey, - @Nonnull byte[] hmac, - @Nonnull Instant now) { - this.subscriberBytes = Objects.requireNonNull(subscriberBytes); - this.subscriberUser = Objects.requireNonNull(subscriberUser); - this.subscriberKey = Objects.requireNonNull(subscriberKey); - this.hmac = Objects.requireNonNull(hmac); - this.now = Objects.requireNonNull(now); - } - - public static RequestData process( - Optional authenticatedAccount, - String subscriberId, - Clock clock) { - Instant now = clock.instant(); - if (authenticatedAccount.isPresent()) { - throw new ForbiddenException("must not use authenticated connection for subscriber operations"); - } - byte[] subscriberBytes = convertSubscriberIdStringToBytes(subscriberId); - byte[] subscriberUser = getUser(subscriberBytes); - byte[] subscriberKey = getKey(subscriberBytes); - byte[] hmac = computeHmac(subscriberUser, subscriberKey); - return new RequestData(subscriberBytes, subscriberUser, subscriberKey, hmac, now); - } - - private static byte[] convertSubscriberIdStringToBytes(String subscriberId) { - try { - byte[] bytes = Base64.getUrlDecoder().decode(subscriberId); - if (bytes.length != 32) { - throw new NotFoundException(); - } - return bytes; - } catch (IllegalArgumentException e) { - throw new NotFoundException(e); - } - } - - private static byte[] getUser(byte[] subscriberBytes) { - byte[] user = new byte[16]; - System.arraycopy(subscriberBytes, 0, user, 0, user.length); - return user; - } - - private static byte[] getKey(byte[] subscriberBytes) { - byte[] key = new byte[16]; - System.arraycopy(subscriberBytes, 16, key, 0, key.length); - return key; - } - - private static byte[] computeHmac(byte[] subscriberUser, byte[] subscriberKey) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(subscriberKey, "HmacSHA256")); - return mac.doFinal(subscriberUser); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new InternalServerErrorException(e); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClient.java deleted file mode 100644 index 0b1fd4e3c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClient.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.google.common.annotations.VisibleForTesting; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import java.io.IOException; -import java.math.BigDecimal; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Map; - -public class CoinMarketCapClient { - - private final HttpClient httpClient; - private final String apiKey; - private final Map currencyIdsBySymbol; - - private static final Logger logger = LoggerFactory.getLogger(CoinMarketCapClient.class); - - record CoinMarketCapResponse(@JsonProperty("data") PriceConversionResponse priceConversionResponse) {}; - - record PriceConversionResponse(int id, String symbol, Map quote) {}; - - record PriceConversionQuote(BigDecimal price) {}; - - public CoinMarketCapClient(final HttpClient httpClient, final String apiKey, final Map currencyIdsBySymbol) { - this.httpClient = httpClient; - this.apiKey = apiKey; - this.currencyIdsBySymbol = currencyIdsBySymbol; - } - - public BigDecimal getSpotPrice(final String currency, final String base) throws IOException { - if (!currencyIdsBySymbol.containsKey(currency)) { - throw new IllegalArgumentException("No currency ID found for " + currency); - } - - final URI quoteUri = URI.create( - String.format("https://pro-api.coinmarketcap.com/v2/tools/price-conversion?amount=1&id=%d&convert=%s", - currencyIdsBySymbol.get(currency), base)); - - try { - final HttpResponse response = httpClient.send(HttpRequest.newBuilder() - .GET() - .uri(quoteUri) - .header("X-CMC_PRO_API_KEY", apiKey) - .build(), - HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() < 200 || response.statusCode() >= 300) { - logger.warn("CoinMarketCapRequest failed with response: {}", response); - throw new IOException("CoinMarketCap request failed with status code " + response.statusCode()); - } - - return extractConversionRate(parseResponse(response.body()), base); - } catch (final InterruptedException e) { - throw new IOException("Interrupted while waiting for a response", e); - } - } - - @VisibleForTesting - static CoinMarketCapResponse parseResponse(final String responseJson) throws JsonProcessingException { - return SystemMapper.getMapper().readValue(responseJson, CoinMarketCapResponse.class); - } - - @VisibleForTesting - static BigDecimal extractConversionRate(final CoinMarketCapResponse response, final String destinationCurrency) - throws IOException { - if (!response.priceConversionResponse().quote.containsKey(destinationCurrency)) { - throw new IOException("Response does not contain conversion rate for " + destinationCurrency); - } - - return response.priceConversionResponse().quote.get(destinationCurrency).price(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java deleted file mode 100644 index a151ffd17..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java +++ /dev/null @@ -1,161 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.lifecycle.Managed; -import io.lettuce.core.SetArgs; -import java.io.IOException; -import java.math.BigDecimal; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.Util; - -public class CurrencyConversionManager implements Managed { - - private static final Logger logger = LoggerFactory.getLogger(CurrencyConversionManager.class); - - @VisibleForTesting - static final Duration FIXER_REFRESH_INTERVAL = Duration.ofHours(2); - - private static final Duration COIN_MARKET_CAP_REFRESH_INTERVAL = Duration.ofMinutes(5); - - @VisibleForTesting - static final String COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY = "CurrencyConversionManager::CoinMarketCapCacheCurrent"; - private static final String COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY = "CurrencyConversionManager::CoinMarketCapCacheData"; - - private final FixerClient fixerClient; - private final CoinMarketCapClient coinMarketCapClient; - private final FaultTolerantRedisCluster cacheCluster; - private final Clock clock; - - private final List currencies; - - private final AtomicReference cached = new AtomicReference<>(null); - - private Instant fixerUpdatedTimestamp = Instant.MIN; - - private Map cachedFixerValues; - private Map cachedCoinMarketCapValues; - - public CurrencyConversionManager(final FixerClient fixerClient, - final CoinMarketCapClient coinMarketCapClient, - final FaultTolerantRedisCluster cacheCluster, - final List currencies, - final Clock clock) { - this.fixerClient = fixerClient; - this.coinMarketCapClient = coinMarketCapClient; - this.cacheCluster = cacheCluster; - this.currencies = currencies; - this.clock = clock; - } - - public Optional getCurrencyConversions() { - return Optional.ofNullable(cached.get()); - } - - @Override - public void start() throws Exception { - new Thread(() -> { - for (;;) { - try { - updateCacheIfNecessary(); - } catch (Throwable t) { - logger.warn("Error updating currency conversions", t); - } - - Util.sleep(15000); - } - }).start(); - } - - @Override - public void stop() throws Exception { - - } - - @VisibleForTesting - void updateCacheIfNecessary() throws IOException { - if (Duration.between(fixerUpdatedTimestamp, clock.instant()).abs().compareTo(FIXER_REFRESH_INTERVAL) >= 0 || cachedFixerValues == null) { - this.cachedFixerValues = new HashMap<>(fixerClient.getConversionsForBase("USD")); - this.fixerUpdatedTimestamp = clock.instant(); - } - - { - final Map coinMarketCapValuesFromSharedCache = cacheCluster.withCluster(connection -> { - final Map parsedSharedCacheData = new HashMap<>(); - - connection.sync().hgetall(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY).forEach((currency, conversionRate) -> - parsedSharedCacheData.put(currency, new BigDecimal(conversionRate))); - - return parsedSharedCacheData; - }); - - if (coinMarketCapValuesFromSharedCache != null && !coinMarketCapValuesFromSharedCache.isEmpty()) { - cachedCoinMarketCapValues = coinMarketCapValuesFromSharedCache; - } - } - - final boolean shouldUpdateSharedCache = cacheCluster.withCluster(connection -> - "OK".equals(connection.sync().set(COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY, - "true", - SetArgs.Builder.nx().ex(COIN_MARKET_CAP_REFRESH_INTERVAL)))); - - if (shouldUpdateSharedCache || cachedCoinMarketCapValues == null) { - final Map conversionRatesFromCoinMarketCap = new HashMap<>(currencies.size()); - - for (final String currency : currencies) { - conversionRatesFromCoinMarketCap.put(currency, coinMarketCapClient.getSpotPrice(currency, "USD")); - } - - cachedCoinMarketCapValues = conversionRatesFromCoinMarketCap; - - if (shouldUpdateSharedCache) { - cacheCluster.useCluster(connection -> { - final Map sharedCoinMarketCapValues = new HashMap<>(); - - cachedCoinMarketCapValues.forEach((currency, conversionRate) -> - sharedCoinMarketCapValues.put(currency, conversionRate.toString())); - - connection.sync().hset(COIN_MARKET_CAP_SHARED_CACHE_DATA_KEY, sharedCoinMarketCapValues); - }); - } - } - - List entities = new LinkedList<>(); - - for (Map.Entry currency : cachedCoinMarketCapValues.entrySet()) { - BigDecimal usdValue = stripTrailingZerosAfterDecimal(currency.getValue()); - - Map values = new HashMap<>(); - values.put("USD", usdValue); - - for (Map.Entry conversion : cachedFixerValues.entrySet()) { - values.put(conversion.getKey(), stripTrailingZerosAfterDecimal(conversion.getValue().multiply(usdValue))); - } - - entities.add(new CurrencyConversionEntity(currency.getKey(), values)); - } - - this.cached.set(new CurrencyConversionEntityList(entities, clock.millis())); - } - - private BigDecimal stripTrailingZerosAfterDecimal(BigDecimal bigDecimal) { - BigDecimal n = bigDecimal.stripTrailingZeros(); - if (n.scale() < 0) { - return n.setScale(0); - } else { - return n; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/currency/FixerClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/currency/FixerClient.java deleted file mode 100644 index 185b46aad..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/currency/FixerClient.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import com.fasterxml.jackson.annotation.JsonProperty; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -import java.io.IOException; -import java.math.BigDecimal; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Map; - -public class FixerClient { - - private final String apiKey; - private final HttpClient client; - - public FixerClient(HttpClient client, String apiKey) { - this.apiKey = apiKey; - this.client = client; - } - - public Map getConversionsForBase(String base) throws FixerException { - try { - URI uri = URI.create("https://data.fixer.io/api/latest?access_key=" + apiKey + "&base=" + base); - - HttpResponse response = client.send(HttpRequest.newBuilder() - .GET() - .uri(uri) - .build(), - HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new FixerException("Bad response: " + response.statusCode() + " " + response.toString()); - } - - FixerResponse parsedResponse = SystemMapper.getMapper().readValue(response.body(), FixerResponse.class); - - if (parsedResponse.success) return parsedResponse.rates; - else throw new FixerException("Got failed response!"); - } catch (IOException | InterruptedException e) { - throw new FixerException(e); - } - } - - private static class FixerResponse { - - @JsonProperty - private boolean success; - - @JsonProperty - private long timestamp; - - @JsonProperty - private String base; - - @JsonProperty - private String date; - - @JsonProperty - private Map rates; - - } - - public static class FixerException extends IOException { - public FixerException(String message) { - super(message); - } - - public FixerException(Exception exception) { - super(exception); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java deleted file mode 100644 index 6f256c1a9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.google.common.annotations.VisibleForTesting; -import java.util.Optional; -import java.util.OptionalInt; -import javax.annotation.Nullable; -import javax.validation.constraints.Size; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; -import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public class AccountAttributes { - - @JsonProperty - private boolean fetchesMessages; - - @JsonProperty - private int registrationId; - - @JsonProperty("pniRegistrationId") - private Integer phoneNumberIdentityRegistrationId; - - @JsonProperty - @Size(max = 204, message = "This field must be less than 50 characters") - private String name; - - @JsonProperty - private String registrationLock; - - @JsonProperty - @ExactlySize({0, 16}) - private byte[] unidentifiedAccessKey; - - @JsonProperty - private boolean unrestrictedUnidentifiedAccess; - - @JsonProperty - private DeviceCapabilities capabilities; - - @JsonProperty - private boolean discoverableByPhoneNumber = true; - - @JsonProperty - @Nullable - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - private byte[] recoveryPassword = null; - - public AccountAttributes() { - } - - @VisibleForTesting - public AccountAttributes( - final boolean fetchesMessages, - final int registrationId, - final String name, - final String registrationLock, - final boolean discoverableByPhoneNumber, - final DeviceCapabilities capabilities) { - this.fetchesMessages = fetchesMessages; - this.registrationId = registrationId; - this.name = name; - this.registrationLock = registrationLock; - this.discoverableByPhoneNumber = discoverableByPhoneNumber; - this.capabilities = capabilities; - } - - public boolean getFetchesMessages() { - return fetchesMessages; - } - - public int getRegistrationId() { - return registrationId; - } - - public OptionalInt getPhoneNumberIdentityRegistrationId() { - return phoneNumberIdentityRegistrationId != null ? OptionalInt.of(phoneNumberIdentityRegistrationId) : OptionalInt.empty(); - } - - public String getName() { - return name; - } - - public String getRegistrationLock() { - return registrationLock; - } - - public byte[] getUnidentifiedAccessKey() { - return unidentifiedAccessKey; - } - - public boolean isUnrestrictedUnidentifiedAccess() { - return unrestrictedUnidentifiedAccess; - } - - public DeviceCapabilities getCapabilities() { - return capabilities; - } - - public boolean isDiscoverableByPhoneNumber() { - return discoverableByPhoneNumber; - } - - public Optional recoveryPassword() { - return Optional.ofNullable(recoveryPassword); - } - - @VisibleForTesting - public AccountAttributes withUnidentifiedAccessKey(final byte[] unidentifiedAccessKey) { - this.unidentifiedAccessKey = unidentifiedAccessKey; - return this; - } - - @VisibleForTesting - public AccountAttributes withRecoveryPassword(final byte[] recoveryPassword) { - this.recoveryPassword = recoveryPassword; - return this; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountCount.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountCount.java deleted file mode 100644 index c1caf6fb5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountCount.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class AccountCount { - - @JsonProperty - private int count; - - public AccountCount(int count) { - this.count = count; - } - - public AccountCount() {} - - public int getCount() { - return count; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentifierResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentifierResponse.java deleted file mode 100644 index dfd33fd7e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentifierResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; -import javax.validation.constraints.NotNull; -import java.util.UUID; - -public record AccountIdentifierResponse(@NotNull UUID uuid) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java deleted file mode 100644 index 450ea51a6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import java.util.UUID; -import javax.annotation.Nullable; - -public record AccountIdentityResponse(UUID uuid, - String number, - UUID pni, - @Nullable byte[] usernameHash, - boolean storageCapable) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountMismatchedDevices.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountMismatchedDevices.java deleted file mode 100644 index 4991355c6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountMismatchedDevices.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.UUID; - -public class AccountMismatchedDevices { - @JsonProperty - public final UUID uuid; - - @JsonProperty - public final MismatchedDevices devices; - - public String toString() { - return "AccountMismatchedDevices(" + uuid + ", " + devices + ")"; - } - - public AccountMismatchedDevices(final UUID uuid, final MismatchedDevices devices) { - this.uuid = uuid; - this.devices = devices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountStaleDevices.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountStaleDevices.java deleted file mode 100644 index bf1282fdc..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountStaleDevices.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.UUID; - -public class AccountStaleDevices { - @JsonProperty - public final UUID uuid; - - @JsonProperty - public final StaleDevices devices; - - public String toString() { - return "AccountStaleDevices(" + uuid + ", " + devices + ")"; - } - - public AccountStaleDevices(final UUID uuid, final StaleDevices devices) { - this.uuid = uuid; - this.devices = devices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AcknowledgeWebsocketMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AcknowledgeWebsocketMessage.java deleted file mode 100644 index 8393ba342..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AcknowledgeWebsocketMessage.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class AcknowledgeWebsocketMessage extends IncomingWebsocketMessage { - - @JsonProperty - private long id; - - public AcknowledgeWebsocketMessage() {} - - public AcknowledgeWebsocketMessage(long id) { - this.type = TYPE_ACKNOWLEDGE_MESSAGE; - this.id = id; - } - - public long getId() { - return id; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ActiveUserTally.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ActiveUserTally.java deleted file mode 100644 index ac9496a7b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ActiveUserTally.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Map; -import java.util.UUID; - -public class ActiveUserTally { - @JsonProperty - private UUID fromUuid; - - @JsonProperty - private Map platforms; - - @JsonProperty - private Map countries; - - public ActiveUserTally() {} - - public ActiveUserTally(UUID fromUuid, Map platforms, Map countries) { - this.fromUuid = fromUuid; - this.platforms = platforms; - this.countries = countries; - } - - public UUID getFromUuid() { - return this.fromUuid; - } - - public Map getPlatforms() { - return this.platforms; - } - - public Map getCountries() { - return this.countries; - } - - public void setFromUuid(UUID fromUuid) { - this.fromUuid = fromUuid; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequest.java deleted file mode 100644 index c7ec4782e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; - -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") -@JsonSubTypes({ - @JsonSubTypes.Type(value = AnswerPushChallengeRequest.class, name = "rateLimitPushChallenge"), - @JsonSubTypes.Type(value = AnswerRecaptchaChallengeRequest.class, name = "recaptcha") -}) -public abstract class AnswerChallengeRequest { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerPushChallengeRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerPushChallengeRequest.java deleted file mode 100644 index 3eaf6c459..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerPushChallengeRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import javax.validation.constraints.NotBlank; - -public class AnswerPushChallengeRequest extends AnswerChallengeRequest { - - @NotBlank - private String challenge; - - public String getChallenge() { - return challenge; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerRecaptchaChallengeRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerRecaptchaChallengeRequest.java deleted file mode 100644 index 09433bd1b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerRecaptchaChallengeRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import javax.validation.constraints.NotBlank; - -public class AnswerRecaptchaChallengeRequest extends AnswerChallengeRequest { - - @NotBlank - private String token; - - @NotBlank - private String captcha; - - public String getToken() { - return token; - } - - public String getCaptcha() { - return captcha; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApnRegistrationId.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApnRegistrationId.java deleted file mode 100644 index c8d288a50..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ApnRegistrationId.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import javax.validation.constraints.NotEmpty; - -public class ApnRegistrationId { - - @JsonProperty - @NotEmpty - private String apnRegistrationId; - - @JsonProperty - private String voipRegistrationId; - - public ApnRegistrationId() {} - - @VisibleForTesting - public ApnRegistrationId(String apnRegistrationId, String voipRegistrationId) { - this.apnRegistrationId = apnRegistrationId; - this.voipRegistrationId = voipRegistrationId; - } - - public String getApnRegistrationId() { - return apnRegistrationId; - } - - public String getVoipRegistrationId() { - return voipRegistrationId; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV2.java deleted file mode 100644 index 94f18d3c0..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV2.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class AttachmentDescriptorV2 { - - @JsonProperty - private String key; - - @JsonProperty - private String credential; - - @JsonProperty - private String acl; - - @JsonProperty - private String algorithm; - - @JsonProperty - private String date; - - @JsonProperty - private String policy; - - @JsonProperty - private String signature; - - @JsonProperty - private long attachmentId; - - @JsonProperty - private String attachmentIdString; - - public AttachmentDescriptorV2() {} - - public AttachmentDescriptorV2(long attachmentId, - String key, String credential, - String acl, String algorithm, - String date, String policy, - String signature) - { - this.attachmentId = attachmentId; - this.attachmentIdString = String.valueOf(attachmentId); - this.key = key; - this.credential = credential; - this.acl = acl; - this.algorithm = algorithm; - this.date = date; - this.policy = policy; - this.signature = signature; - } - - public String getKey() { - return key; - } - - public String getCredential() { - return credential; - } - - public String getAcl() { - return acl; - } - - public String getAlgorithm() { - return algorithm; - } - - public String getDate() { - return date; - } - - public String getPolicy() { - return policy; - } - - public String getSignature() { - return signature; - } - - public long getAttachmentId() { - return attachmentId; - } - - public String getAttachmentIdString() { - return attachmentIdString; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java deleted file mode 100644 index fabca6a4a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.Map; - -public class AttachmentDescriptorV3 { - - @JsonProperty - private int cdn; - - @JsonProperty - private String key; - - @JsonProperty - private Map headers; - - @JsonProperty - private String signedUploadLocation; - - public AttachmentDescriptorV3() { - } - - public AttachmentDescriptorV3(int cdn, String key, Map headers, String signedUploadLocation) { - this.cdn = cdn; - this.key = key; - this.headers = headers; - this.signedUploadLocation = signedUploadLocation; - } - - public int getCdn() { - return cdn; - } - - public String getKey() { - return key; - } - - public Map getHeaders() { - return headers; - } - - public String getSignedUploadLocation() { - return signedUploadLocation; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentUri.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentUri.java deleted file mode 100644 index 0531c76c5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentUri.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; - -public class AttachmentUri { - - @JsonProperty - private String location; - - public AttachmentUri(URL uri) { - this.location = uri.toString(); - } - - public AttachmentUri() {} - - public URL getLocation() throws MalformedURLException { - return URI.create(location).toURL(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java deleted file mode 100644 index e8a4a3500..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import java.util.List; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; -import org.whispersystems.textsecuregcm.util.E164; - -public record AuthCheckRequest(@NotNull @E164 String number, - @NotEmpty @Size(max = 10) List passwords) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponse.java deleted file mode 100644 index a9645b0ea..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponse.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonValue; -import java.util.Map; -import javax.validation.constraints.NotNull; - -public record AuthCheckResponse(@NotNull Map matches) { - - public enum Result { - MATCH("match"), - NO_MATCH("no-match"), - INVALID("invalid"); - - private final String clientCode; - - Result(final String clientCode) { - this.clientCode = clientCode; - } - - @JsonValue - public String clientCode() { - return clientCode; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java deleted file mode 100644 index ff4441fb8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.base.Strings; -import java.util.List; -import java.util.Objects; - -public class Badge { - private final String id; - private final String category; - private final String name; - private final String description; - private final List sprites6; - private final String svg; - private final List svgs; - - @JsonCreator - public Badge( - @JsonProperty("id") final String id, - @JsonProperty("category") final String category, - @JsonProperty("name") final String name, - @JsonProperty("description") final String description, - @JsonProperty("sprites6") final List sprites6, - @JsonProperty("svg") final String svg, - @JsonProperty("svgs") final List svgs) { - this.id = id; - this.category = category; - this.name = name; - this.description = description; - this.sprites6 = Objects.requireNonNull(sprites6); - if (sprites6.size() != 6) { - throw new IllegalArgumentException("sprites must have size 6"); - } - if (Strings.isNullOrEmpty(svg)) { - throw new IllegalArgumentException("svg cannot be empty"); - } - this.svg = svg; - this.svgs = Objects.requireNonNull(svgs); - } - - public String getId() { - return id; - } - - public String getCategory() { - return category; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public List getSprites6() { - return sprites6; - } - - public String getSvg() { - return svg; - } - - public List getSvgs() { - return svgs; - } - - /** - * Workaround for old Android builds that expect this field to exist but don't care it's an empty string. - */ - @Deprecated - @JsonProperty - public String getImageUrl() { - return ""; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Badge badge = (Badge) o; - return Objects.equals(id, badge.id) - && Objects.equals(category, badge.category) - && Objects.equals(name, badge.name) - && Objects.equals(description, badge.description) - && Objects.equals(sprites6, badge.sprites6) - && Objects.equals(svg, badge.svg) - && Objects.equals(svgs, badge.svgs); - } - - @Override - public int hashCode() { - return Objects.hash(id, category, name, description, sprites6, svg, svgs); - } - - @Override - public String toString() { - return "Badge{" + - "id='" + id + '\'' + - ", category='" + category + '\'' + - ", name='" + name + '\'' + - ", description='" + description + '\'' + - ", sprites6=" + sprites6 + - ", svg='" + svg + '\'' + - ", svgs=" + svgs + - '}'; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BadgeSvg.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BadgeSvg.java deleted file mode 100644 index fef8cda78..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BadgeSvg.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.base.Strings; -import java.util.Objects; -import javax.validation.constraints.NotEmpty; - -public class BadgeSvg { - private final String light; - private final String dark; - - @JsonCreator - public BadgeSvg( - @JsonProperty("light") final String light, - @JsonProperty("dark") final String dark) { - if (Strings.isNullOrEmpty(light)) { - throw new IllegalArgumentException("light cannot be empty"); - } - this.light = light; - if (Strings.isNullOrEmpty(dark)) { - throw new IllegalArgumentException("dark cannot be empty"); - } - this.dark = dark; - } - - @NotEmpty - public String getLight() { - return light; - } - - @NotEmpty - public String getDark() { - return dark; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - BadgeSvg badgeSvg = (BadgeSvg) o; - return Objects.equals(light, badgeSvg.light) - && Objects.equals(dark, badgeSvg.dark); - } - - @Override - public int hashCode() { - return Objects.hash(light, dark); - } - - @Override - public String toString() { - return "BadgeSvg{" + - "light='" + light + '\'' + - ", dark='" + dark + '\'' + - '}'; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java deleted file mode 100644 index 1d3a2719c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import java.util.UUID; - -public class BaseProfileResponse { - - @JsonProperty - private String identityKey; - - @JsonProperty - private String unidentifiedAccess; - - @JsonProperty - private boolean unrestrictedUnidentifiedAccess; - - @JsonProperty - private UserCapabilities capabilities; - - @JsonProperty - private List badges; - - @JsonProperty - private UUID uuid; - - public BaseProfileResponse() { - } - - public BaseProfileResponse(final String identityKey, - final String unidentifiedAccess, - final boolean unrestrictedUnidentifiedAccess, - final UserCapabilities capabilities, - final List badges, - final UUID uuid) { - - this.identityKey = identityKey; - this.unidentifiedAccess = unidentifiedAccess; - this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess; - this.capabilities = capabilities; - this.badges = badges; - this.uuid = uuid; - } - - public String getIdentityKey() { - return identityKey; - } - - public String getUnidentifiedAccess() { - return unidentifiedAccess; - } - - public boolean isUnrestrictedUnidentifiedAccess() { - return unrestrictedUnidentifiedAccess; - } - - public UserCapabilities getCapabilities() { - return capabilities; - } - - public List getBadges() { - return badges; - } - - public UUID getUuid() { - return uuid; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckRequest.java deleted file mode 100644 index 54bf15418..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import java.util.List; -import java.util.UUID; -import javax.annotation.Nullable; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public record BatchIdentityCheckRequest(@Valid @NotNull @Size(max = 1000) List elements) { - - /** - * @param uuid account id or phone number id - * @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519 public - * key prefixed with 0x05) - */ - public record Element(@Deprecated @Nullable UUID aci, - @Nullable UUID uuid, - @NotNull @ExactlySize(4) byte[] fingerprint) { - - public Element { - if (aci == null && uuid == null) { - throw new IllegalArgumentException("aci and uuid cannot both be null"); - } - - if (aci != null && uuid != null) { - throw new IllegalArgumentException("aci and uuid cannot both be non-null"); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckResponse.java deleted file mode 100644 index becb15f73..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckResponse.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonInclude; -import java.util.List; -import java.util.UUID; -import javax.annotation.Nullable; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public record BatchIdentityCheckResponse(@Valid List elements) { - - public record Element(@Deprecated @JsonInclude(JsonInclude.Include.NON_EMPTY) @Nullable UUID aci, - @JsonInclude(JsonInclude.Include.NON_EMPTY) @Nullable UUID uuid, - @NotNull @ExactlySize(33) byte[] identityKey) { - - public Element { - if (aci == null && uuid == null) { - throw new IllegalArgumentException("aci and uuid cannot both be null"); - } - - if (aci != null && uuid != null) { - throw new IllegalArgumentException("aci and uuid cannot both be non-null"); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java deleted file mode 100644 index 3d0d63042..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import java.util.List; -import java.util.Map; -import javax.annotation.Nullable; -import javax.validation.Valid; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; - -public record ChangeNumberRequest(String sessionId, - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) byte[] recoveryPassword, - @NotBlank String number, - @JsonProperty("reglock") @Nullable String registrationLock, - @NotBlank String pniIdentityKey, - @NotNull @Valid List<@NotNull @Valid IncomingMessage> deviceMessages, - @NotNull @Valid Map devicePniSignedPrekeys, - @NotNull Map pniRegistrationIds) implements PhoneVerificationRequest { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangePhoneNumberRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangePhoneNumberRequest.java deleted file mode 100644 index 49970b270..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangePhoneNumberRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import java.util.Map; -import javax.annotation.Nullable; -import javax.validation.constraints.NotBlank; - -public record ChangePhoneNumberRequest(@NotBlank String number, - @NotBlank String code, - @JsonProperty("reglock") @Nullable String registrationLock, - @Nullable String pniIdentityKey, - @Nullable List deviceMessages, - @Nullable Map devicePniSignedPrekeys, - @Nullable Map pniRegistrationIds) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java deleted file mode 100644 index eb481aa3e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.controllers.AccountController; -import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public record ConfirmUsernameHashRequest( - @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) - @ExactlySize(AccountController.USERNAME_HASH_LENGTH) - byte[] usernameHash, - - @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) - byte[] zkProof -) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java deleted file mode 100644 index e98e9e668..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import java.util.List; -import java.util.Optional; -import javax.annotation.Nullable; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; -import org.apache.commons.lang3.StringUtils; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; -import org.whispersystems.textsecuregcm.util.ExactlySize; - -public class CreateProfileRequest { - - @JsonProperty - @NotEmpty - private String version; - - @JsonProperty - @ExactlySize({108, 380}) - private String name; - - @JsonProperty - private boolean avatar; - - @JsonProperty - private boolean sameAvatar; - - @JsonProperty - @ExactlySize({0, 80}) - private String aboutEmoji; - - @JsonProperty - @ExactlySize({0, 208, 376, 720}) - private String about; - - @JsonProperty - @ExactlySize({0, 776}) - private String paymentAddress; - - @JsonProperty - @Nullable - private List badgeIds; - - @JsonProperty - @NotNull - @JsonDeserialize(using = ProfileKeyCommitmentAdapter.Deserializing.class) - @JsonSerialize(using = ProfileKeyCommitmentAdapter.Serializing.class) - private ProfileKeyCommitment commitment; - - public CreateProfileRequest() { - } - - public CreateProfileRequest( - ProfileKeyCommitment commitment, String version, String name, String aboutEmoji, String about, - String paymentAddress, boolean wantsAvatar, boolean sameAvatar, List badgeIds) { - this.commitment = commitment; - this.version = version; - this.name = name; - this.aboutEmoji = aboutEmoji; - this.about = about; - this.paymentAddress = paymentAddress; - this.avatar = wantsAvatar; - this.sameAvatar = sameAvatar; - this.badgeIds = badgeIds; - } - - public ProfileKeyCommitment getCommitment() { - return commitment; - } - - public String getVersion() { - return version; - } - - public String getName() { - return name; - } - - public boolean hasAvatar() { - return avatar; - } - - public enum AvatarChange { - UNCHANGED, - CLEAR, - UPDATE; - } - - public AvatarChange getAvatarChange() { - if (!hasAvatar()) { - return AvatarChange.CLEAR; - } - if (!sameAvatar) { - return AvatarChange.UPDATE; - } - return AvatarChange.UNCHANGED; - } - - public String getAboutEmoji() { - return StringUtils.stripToNull(aboutEmoji); - } - - public String getAbout() { - return StringUtils.stripToNull(about); - } - - public String getPaymentAddress() { - return StringUtils.stripToNull(paymentAddress); - } - - public Optional> getBadges() { - return Optional.ofNullable(badgeIds); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CredentialProfileResponse.java deleted file mode 100644 index 01329d466..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CredentialProfileResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonUnwrapped; - -public abstract class CredentialProfileResponse { - - @JsonUnwrapped - private VersionedProfileResponse versionedProfileResponse; - - protected CredentialProfileResponse() { - } - - protected CredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse) { - this.versionedProfileResponse = versionedProfileResponse; - } - - public VersionedProfileResponse getVersionedProfileResponse() { - return versionedProfileResponse; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntity.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntity.java deleted file mode 100644 index c937b16fc..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntity.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.math.BigDecimal; -import java.util.Map; - -public class CurrencyConversionEntity { - - @JsonProperty - private String base; - - @JsonProperty - private Map conversions; - - public CurrencyConversionEntity(String base, Map conversions) { - this.base = base; - this.conversions = conversions; - } - - public CurrencyConversionEntity() {} - - public String getBase() { - return base; - } - - public Map getConversions() { - return conversions; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntityList.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntityList.java deleted file mode 100644 index 91c101615..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntityList.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -public class CurrencyConversionEntityList { - - @JsonProperty - private List currencies; - - @JsonProperty - private long timestamp; - - public CurrencyConversionEntityList(List currencies, long timestamp) { - this.currencies = currencies; - this.timestamp = timestamp; - } - - public CurrencyConversionEntityList() {} - - public List getCurrencies() { - return currencies; - } - - public long getTimestamp() { - return timestamp; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java deleted file mode 100644 index 49dd46503..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -public class DeliveryCertificate { - - private final byte[] certificate; - - @JsonCreator - public DeliveryCertificate( - @JsonProperty("certificate") byte[] certificate) { - this.certificate = certificate; - } - - public byte[] getCertificate() { - return certificate; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java deleted file mode 100644 index f64b78661..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class DeviceInfo { - @JsonProperty - private long id; - - @JsonProperty - private String name; - - @JsonProperty - private long lastSeen; - - @JsonProperty - private long created; - - public DeviceInfo(long id, String name, long lastSeen, long created) { - this.id = id; - this.name = name; - this.lastSeen = lastSeen; - this.created = created; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfoList.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfoList.java deleted file mode 100644 index 55473ecd9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfoList.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -public class DeviceInfoList { - - @JsonProperty - private List devices; - - public DeviceInfoList(List devices) { - this.devices = devices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceName.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceName.java deleted file mode 100644 index 498c4032b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceName.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.Size; - -public class DeviceName { - - @JsonProperty - @NotEmpty - @Size(max = 300, message = "This field must be less than 300 characters") - private String deviceName; - - public DeviceName() {} - - public String getDeviceName() { - return deviceName; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceResponse.java deleted file mode 100644 index 4539ae2a0..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceResponse.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; - -import java.util.UUID; - -public class DeviceResponse { - @JsonProperty - private UUID uuid; - - @JsonProperty - private UUID pni; - - @JsonProperty - private long deviceId; - - @VisibleForTesting - public DeviceResponse() {} - - public DeviceResponse(UUID uuid, UUID pni, long deviceId) { - this.uuid = uuid; - this.pni = pni; - this.deviceId = deviceId; - } - - public UUID getUuid() { - return uuid; - } - - public UUID getPni() { - return pni; - } - - public long getDeviceId() { - return deviceId; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DirectoryReconciliationRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DirectoryReconciliationRequest.java deleted file mode 100644 index d6455c4f2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DirectoryReconciliationRequest.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import java.util.UUID; - -public class DirectoryReconciliationRequest { - - @JsonProperty - private List users; - - public DirectoryReconciliationRequest() { - } - - public DirectoryReconciliationRequest(List users) { - this.users = users; - } - - public List getUsers() { - return users; - } - - public static class User { - - @JsonProperty - private UUID uuid; - - @JsonProperty - private String number; - - public User() { - } - - public User(UUID uuid, String number) { - this.uuid = uuid; - this.number = number; - } - - public UUID getUuid() { - return uuid; - } - - public String getNumber() { - return number; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - User user = (User) o; - - if (uuid != null ? !uuid.equals(user.uuid) : user.uuid != null) return false; - if (number != null ? !number.equals(user.number) : user.number != null) return false; - - return true; - } - - @Override - public int hashCode() { - int result = uuid != null ? uuid.hashCode() : 0; - result = 31 * result + (number != null ? number.hashCode() : 0); - return result; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DirectoryReconciliationResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/DirectoryReconciliationResponse.java deleted file mode 100644 index fdf72a8b9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/DirectoryReconciliationResponse.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; - -public class DirectoryReconciliationResponse { - - @JsonProperty - @NotEmpty - private Status status; - - public DirectoryReconciliationResponse() { - } - - public DirectoryReconciliationResponse(Status status) { - this.status = status; - } - - public Status getStatus() { - return status; - } - - public enum Status { - OK, - MISSING, - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialProfileResponse.java deleted file mode 100644 index 5ac0566a1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialProfileResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import javax.annotation.Nullable; -import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; - -public class ExpiringProfileKeyCredentialProfileResponse extends CredentialProfileResponse { - - @JsonProperty - @JsonSerialize(using = ExpiringProfileKeyCredentialResponseAdapter.Serializing.class) - @JsonDeserialize(using = ExpiringProfileKeyCredentialResponseAdapter.Deserializing.class) - @Nullable - private ExpiringProfileKeyCredentialResponse credential; - - public ExpiringProfileKeyCredentialProfileResponse() { - } - - public ExpiringProfileKeyCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse, - @Nullable final ExpiringProfileKeyCredentialResponse credential) { - - super(versionedProfileResponse); - this.credential = credential; - } - - @Nullable - public ExpiringProfileKeyCredentialResponse getCredential() { - return credential; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialResponseAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialResponseAdapter.java deleted file mode 100644 index acca13d68..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialResponseAdapter.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import java.io.IOException; -import java.util.Base64; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; - -public class ExpiringProfileKeyCredentialResponseAdapter { - - public static class Serializing extends JsonSerializer { - @Override - public void serialize(ExpiringProfileKeyCredentialResponse response, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) - throws IOException { - if (response == null) jsonGenerator.writeNull(); - else jsonGenerator.writeString(Base64.getEncoder().encodeToString(response.serialize())); - } - } - - public static class Deserializing extends JsonDeserializer { - @Override - public ExpiringProfileKeyCredentialResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) - throws IOException { - try { - return new ExpiringProfileKeyCredentialResponse(Base64.getDecoder().decode(jsonParser.getValueAsString())); - } catch (InvalidInputException e) { - throw new IOException(e); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GcmRegistrationId.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GcmRegistrationId.java deleted file mode 100644 index a52349cf4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GcmRegistrationId.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import javax.validation.constraints.NotEmpty; - -public class GcmRegistrationId { - - @JsonProperty - @NotEmpty - private String gcmRegistrationId; - - public GcmRegistrationId() {} - - @VisibleForTesting - public GcmRegistrationId(String id) { - this.gcmRegistrationId = id; - } - - public String getGcmRegistrationId() { - return gcmRegistrationId; - } - - -} - diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java deleted file mode 100644 index 1348c18f6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import java.util.List; -import java.util.UUID; -import javax.annotation.Nullable; - -public record GroupCredentials(List credentials, @Nullable UUID pni) { - - public record GroupCredential(byte[] credential, long redemptionTime) { - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessage.java deleted file mode 100644 index 17496e9a7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessage.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.google.protobuf.ByteString; -import java.util.Base64; -import java.util.UUID; -import javax.annotation.Nullable; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.storage.Account; - -public record IncomingMessage(int type, long destinationDeviceId, int destinationRegistrationId, String content) { - - public MessageProtos.Envelope toEnvelope(final UUID destinationUuid, - @Nullable Account sourceAccount, - @Nullable Long sourceDeviceId, - final long timestamp, - final boolean story, - final boolean urgent, - @Nullable byte[] reportSpamToken) { - - final MessageProtos.Envelope.Type envelopeType = MessageProtos.Envelope.Type.forNumber(type()); - - if (envelopeType == null) { - throw new IllegalArgumentException("Bad envelope type: " + type()); - } - - final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder(); - - envelopeBuilder.setType(envelopeType) - .setTimestamp(timestamp) - .setServerTimestamp(System.currentTimeMillis()) - .setDestinationUuid(destinationUuid.toString()) - .setStory(story) - .setUrgent(urgent); - - if (sourceAccount != null && sourceDeviceId != null) { - envelopeBuilder - .setSourceUuid(sourceAccount.getUuid().toString()) - .setSourceDevice(sourceDeviceId.intValue()); - } - - if (reportSpamToken != null) { - envelopeBuilder.setReportSpamToken(ByteString.copyFrom(reportSpamToken)); - } - - if (StringUtils.isNotEmpty(content())) { - envelopeBuilder.setContent(ByteString.copyFrom(Base64.getDecoder().decode(content()))); - } - - return envelopeBuilder.build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessageList.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessageList.java deleted file mode 100644 index d5bb2af4e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessageList.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; - -public record IncomingMessageList(@NotNull @Valid List<@NotNull IncomingMessage> messages, - boolean online, boolean urgent, long timestamp) { - - @JsonCreator - public IncomingMessageList(@JsonProperty("messages") @NotNull @Valid List<@NotNull IncomingMessage> messages, - @JsonProperty("online") boolean online, - @JsonProperty("urgent") Boolean urgent, - @JsonProperty("timestamp") long timestamp) { - - this(messages, online, urgent == null || urgent, timestamp); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingWebsocketMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingWebsocketMessage.java deleted file mode 100644 index bc2ff8945..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingWebsocketMessage.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class IncomingWebsocketMessage { - - public static final int TYPE_ACKNOWLEDGE_MESSAGE = 1; - public static final int TYPE_PING_MESSAGE = 2; - public static final int TYPE_PONG_MESSAGE = 3; - - @JsonProperty - protected int type; - - public IncomingWebsocketMessage() {} - - public IncomingWebsocketMessage(int type) { - this.type = type; - } - - public int getType() { - return type; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/MessageResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/MessageResponse.java deleted file mode 100644 index a5c04df3a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/MessageResponse.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Set; - -public class MessageResponse { - private List success; - private List failure; - private Set missingDeviceIds; - - public MessageResponse(List success, List failure) { - this.success = success; - this.failure = failure; - this.missingDeviceIds = new HashSet<>(); - } - - public MessageResponse(Set missingDeviceIds) { - this.success = new LinkedList<>(); - this.failure = new LinkedList<>(missingDeviceIds); - this.missingDeviceIds = missingDeviceIds; - } - - public MessageResponse() {} - - public List getSuccess() { - return success; - } - - public void setSuccess(List success) { - this.success = success; - } - - public List getFailure() { - return failure; - } - - public void setFailure(List failure) { - this.failure = failure; - } - - public Set getNumbersMissingDevices() { - return missingDeviceIds; - } - - public void setNumbersMissingDevices(Set numbersMissingDevices) { - this.missingDeviceIds = numbersMissingDevices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/MismatchedDevices.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/MismatchedDevices.java deleted file mode 100644 index 7f45dc82b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/MismatchedDevices.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; - -import java.util.List; - -public class MismatchedDevices { - - @JsonProperty - public List missingDevices; - - @JsonProperty - public List extraDevices; - - @VisibleForTesting - public MismatchedDevices() {} - - public String toString() { - return "MismatchedDevices(" + missingDevices + ", " + extraDevices + ")"; - } - - public MismatchedDevices(List missingDevices, List extraDevices) { - this.missingDevices = missingDevices; - this.extraDevices = extraDevices; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/MultiRecipientMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/MultiRecipientMessage.java deleted file mode 100644 index aecba4ac4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/MultiRecipientMessage.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import java.util.Arrays; -import java.util.UUID; -import javax.validation.Valid; -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; -import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; - -public class MultiRecipientMessage { - - public static class Recipient { - - @NotNull - private final UUID uuid; - - @Min(1) - private final long deviceId; - - @Min(0) - @Max(65535) - private final int registrationId; - - @Size(min = 48, max = 48) - @NotNull - private final byte[] perRecipientKeyMaterial; - - public Recipient(UUID uuid, long deviceId, int registrationId, byte[] perRecipientKeyMaterial) { - this.uuid = uuid; - this.deviceId = deviceId; - this.registrationId = registrationId; - this.perRecipientKeyMaterial = perRecipientKeyMaterial; - } - - public UUID getUuid() { - return uuid; - } - - public long getDeviceId() { - return deviceId; - } - - public int getRegistrationId() { - return registrationId; - } - - public byte[] getPerRecipientKeyMaterial() { - return perRecipientKeyMaterial; - } - - @Override - public boolean equals(final Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - - Recipient recipient = (Recipient) o; - - if (deviceId != recipient.deviceId) - return false; - if (registrationId != recipient.registrationId) - return false; - if (!uuid.equals(recipient.uuid)) - return false; - return Arrays.equals(perRecipientKeyMaterial, recipient.perRecipientKeyMaterial); - } - - @Override - public int hashCode() { - int result = uuid.hashCode(); - result = 31 * result + (int) (deviceId ^ (deviceId >>> 32)); - result = 31 * result + registrationId; - result = 31 * result + Arrays.hashCode(perRecipientKeyMaterial); - return result; - } - - public String toString() { - return "Recipient(" + uuid + ", " + deviceId + ", " + registrationId + ", " + Arrays.toString(perRecipientKeyMaterial) + ")"; - } - } - - @NotNull - @Size(min = 1, max = MultiRecipientMessageProvider.MAX_RECIPIENT_COUNT) - @Valid - private final Recipient[] recipients; - - @NotNull - @Size(min = 32) - private final byte[] commonPayload; - - public MultiRecipientMessage(Recipient[] recipients, byte[] commonPayload) { - this.recipients = recipients; - this.commonPayload = commonPayload; - } - - public Recipient[] getRecipients() { - return recipients; - } - - public byte[] getCommonPayload() { - return commonPayload; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntity.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntity.java deleted file mode 100644 index 4de88c0b3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntity.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.google.protobuf.ByteString; -import java.util.Arrays; -import java.util.Objects; -import java.util.UUID; -import javax.annotation.Nullable; - -public record OutgoingMessageEntity(UUID guid, int type, long timestamp, @Nullable UUID sourceUuid, int sourceDevice, - UUID destinationUuid, @Nullable UUID updatedPni, byte[] content, - long serverTimestamp, boolean urgent, boolean story) { - - public MessageProtos.Envelope toEnvelope() { - final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder() - .setType(MessageProtos.Envelope.Type.forNumber(type())) - .setTimestamp(timestamp()) - .setServerTimestamp(serverTimestamp()) - .setDestinationUuid(destinationUuid().toString()) - .setServerGuid(guid().toString()) - .setStory(story) - .setUrgent(urgent); - - if (sourceUuid() != null) { - builder.setSourceUuid(sourceUuid().toString()); - builder.setSourceDevice(sourceDevice()); - } - - if (content() != null) { - builder.setContent(ByteString.copyFrom(content())); - } - - if (updatedPni() != null) { - builder.setUpdatedPni(updatedPni().toString()); - } - - return builder.build(); - } - - public static OutgoingMessageEntity fromEnvelope(final MessageProtos.Envelope envelope) { - return new OutgoingMessageEntity( - UUID.fromString(envelope.getServerGuid()), - envelope.getType().getNumber(), - envelope.getTimestamp(), - envelope.hasSourceUuid() ? UUID.fromString(envelope.getSourceUuid()) : null, - envelope.getSourceDevice(), - envelope.hasDestinationUuid() ? UUID.fromString(envelope.getDestinationUuid()) : null, - envelope.hasUpdatedPni() ? UUID.fromString(envelope.getUpdatedPni()) : null, - envelope.getContent().toByteArray(), - envelope.getServerTimestamp(), - envelope.getUrgent(), - envelope.getStory()); - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - final OutgoingMessageEntity that = (OutgoingMessageEntity) o; - return guid.equals(that.guid) && - type == that.type && - timestamp == that.timestamp && - Objects.equals(sourceUuid, that.sourceUuid) && - sourceDevice == that.sourceDevice && - destinationUuid.equals(that.destinationUuid) && - Objects.equals(updatedPni, that.updatedPni) && - Arrays.equals(content, that.content) && - serverTimestamp == that.serverTimestamp && - urgent == that.urgent && - story == that.story; - } - - @Override - public int hashCode() { - int result = Objects.hash(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni, - serverTimestamp, urgent, story); - result = 31 * result + Arrays.hashCode(content); - return result; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityList.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityList.java deleted file mode 100644 index 40447431d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityList.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import java.util.List; - -public record OutgoingMessageEntityList(List messages, boolean more) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberDiscoverabilityRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberDiscoverabilityRequest.java deleted file mode 100644 index ba0e52a2f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberDiscoverabilityRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.whispersystems.textsecuregcm.entities; - -import javax.validation.constraints.NotNull; - -public record PhoneNumberDiscoverabilityRequest(@NotNull Boolean discoverableByPhoneNumber) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneVerificationRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneVerificationRequest.java deleted file mode 100644 index c471936cf..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneVerificationRequest.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import static org.apache.commons.lang3.StringUtils.isNotBlank; - -import java.util.Base64; -import javax.validation.constraints.AssertTrue; -import javax.ws.rs.ClientErrorException; -import org.apache.http.HttpStatus; - -public interface PhoneVerificationRequest { - - enum VerificationType { - SESSION, - RECOVERY_PASSWORD - } - - String sessionId(); - - byte[] recoveryPassword(); - - // for the @AssertTrue to work with bean validation, method name must follow 'isSmth()'/'getSmth()' naming convention - @AssertTrue - default boolean isValid() { - // checking that exactly one of sessionId/recoveryPassword is non-empty - return isNotBlank(sessionId()) ^ (recoveryPassword() != null && recoveryPassword().length > 0); - } - - default PhoneVerificationRequest.VerificationType verificationType() { - return isNotBlank(sessionId()) ? PhoneVerificationRequest.VerificationType.SESSION - : PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD; - } - - default byte[] decodeSessionId() { - try { - return Base64.getUrlDecoder().decode(sessionId()); - } catch (final IllegalArgumentException e) { - throw new ClientErrorException("Malformed session ID", HttpStatus.SC_UNPROCESSABLE_ENTITY); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialProfileResponse.java deleted file mode 100644 index 327bf0888..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialProfileResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import javax.annotation.Nullable; -import org.signal.libsignal.zkgroup.profiles.PniCredentialResponse; - -public class PniCredentialProfileResponse extends CredentialProfileResponse { - - @JsonProperty - @JsonSerialize(using = PniCredentialResponseAdapter.Serializing.class) - @JsonDeserialize(using = PniCredentialResponseAdapter.Deserializing.class) - @Nullable - private PniCredentialResponse pniCredential; - - public PniCredentialProfileResponse() { - } - - public PniCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse, - @Nullable final PniCredentialResponse pniCredential) { - - super(versionedProfileResponse); - this.pniCredential = pniCredential; - } - - @Nullable - public PniCredentialResponse getPniCredential() { - return pniCredential; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialResponseAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialResponseAdapter.java deleted file mode 100644 index a981bd0d1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PniCredentialResponseAdapter.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import java.io.IOException; -import java.util.Base64; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.profiles.PniCredentialResponse; - -public class PniCredentialResponseAdapter { - - public static class Serializing extends JsonSerializer { - @Override - public void serialize(PniCredentialResponse response, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) - throws IOException { - if (response == null) jsonGenerator.writeNull(); - else jsonGenerator.writeString(Base64.getEncoder().encodeToString(response.serialize())); - } - } - - public static class Deserializing extends JsonDeserializer { - @Override - public PniCredentialResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) - throws IOException { - try { - return new PniCredentialResponse(Base64.getDecoder().decode(jsonParser.getValueAsString())); - } catch (InvalidInputException e) { - throw new IOException(e); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKey.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKey.java deleted file mode 100644 index d24dc509f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKey.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class PreKey { - - @JsonProperty - @NotNull - private long keyId; - - @JsonProperty - @NotEmpty - private String publicKey; - - public PreKey() {} - - public PreKey(long keyId, String publicKey) - { - this.keyId = keyId; - this.publicKey = publicKey; - } - - public String getPublicKey() { - return publicKey; - } - - public void setPublicKey(String publicKey) { - this.publicKey = publicKey; - } - - public long getKeyId() { - return keyId; - } - - public void setKeyId(long keyId) { - this.keyId = keyId; - } - - @Override - public boolean equals(Object object) { - if (object == null || !(object instanceof PreKey)) return false; - PreKey that = (PreKey)object; - - if (publicKey == null) { - return this.keyId == that.keyId && that.publicKey == null; - } else { - return this.keyId == that.keyId && this.publicKey.equals(that.publicKey); - } - } - - @Override - public int hashCode() { - if (publicKey == null) { - return (int)this.keyId; - } else { - return ((int)this.keyId) ^ publicKey.hashCode(); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyCount.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyCount.java deleted file mode 100644 index 27df671c3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyCount.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class PreKeyCount { - - @JsonProperty - private int count; - - public PreKeyCount(int count) { - this.count = count; - } - - public PreKeyCount() {} - - public int getCount() { - return count; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponse.java deleted file mode 100644 index 82cf7ad91..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponse.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; - -import java.util.List; - -public class PreKeyResponse { - - @JsonProperty - private String identityKey; - - @JsonProperty - private List devices; - - public PreKeyResponse() {} - - public PreKeyResponse(String identityKey, List devices) { - this.identityKey = identityKey; - this.devices = devices; - } - - @VisibleForTesting - public String getIdentityKey() { - return identityKey; - } - - @VisibleForTesting - @JsonIgnore - public PreKeyResponseItem getDevice(int deviceId) { - for (PreKeyResponseItem device : devices) { - if (device.getDeviceId() == deviceId) return device; - } - - return null; - } - - @VisibleForTesting - @JsonIgnore - public int getDevicesCount() { - return devices.size(); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponseItem.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponseItem.java deleted file mode 100644 index 6e9d6bd1f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponseItem.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; - -public class PreKeyResponseItem { - - @JsonProperty - private long deviceId; - - @JsonProperty - private int registrationId; - - @JsonProperty - private SignedPreKey signedPreKey; - - @JsonProperty - private PreKey preKey; - - public PreKeyResponseItem() {} - - public PreKeyResponseItem(long deviceId, int registrationId, SignedPreKey signedPreKey, PreKey preKey) { - this.deviceId = deviceId; - this.registrationId = registrationId; - this.signedPreKey = signedPreKey; - this.preKey = preKey; - } - - @VisibleForTesting - public SignedPreKey getSignedPreKey() { - return signedPreKey; - } - - @VisibleForTesting - public PreKey getPreKey() { - return preKey; - } - - @VisibleForTesting - public int getRegistrationId() { - return registrationId; - } - - @VisibleForTesting - public long getDeviceId() { - return deviceId; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyState.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyState.java deleted file mode 100644 index 18b4cab1c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyState.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import java.util.List; -import javax.validation.Valid; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public class PreKeyState { - - @JsonProperty - @NotNull - @Valid - private List preKeys; - - @JsonProperty - @NotNull - @Valid - private SignedPreKey signedPreKey; - - @JsonProperty - @NotEmpty - private String identityKey; - - public PreKeyState() {} - - @VisibleForTesting - public PreKeyState(String identityKey, SignedPreKey signedPreKey, List keys) { - this.identityKey = identityKey; - this.signedPreKey = signedPreKey; - this.preKeys = keys; - } - - public List getPreKeys() { - return preKeys; - } - - public SignedPreKey getSignedPreKey() { - return signedPreKey; - } - - public String getIdentityKey() { - return identityKey; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java deleted file mode 100644 index 77b1016fa..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class ProfileAvatarUploadAttributes { - - @JsonProperty - private String key; - - @JsonProperty - private String credential; - - @JsonProperty - private String acl; - - @JsonProperty - private String algorithm; - - @JsonProperty - private String date; - - @JsonProperty - private String policy; - - @JsonProperty - private String signature; - - public ProfileAvatarUploadAttributes() {} - - public ProfileAvatarUploadAttributes(String key, String credential, - String acl, String algorithm, - String date, String policy, - String signature) - { - this.key = key; - this.credential = credential; - this.acl = acl; - this.algorithm = algorithm; - this.date = date; - this.policy = policy; - this.signature = signature; - } - - public String getKey() { - return key; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java deleted file mode 100644 index 29fd82e7c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import java.io.IOException; -import java.util.Base64; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; - -public class ProfileKeyCommitmentAdapter { - - public static class Serializing extends JsonSerializer { - @Override - public void serialize(ProfileKeyCommitment value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - gen.writeString(Base64.getEncoder().encodeToString(value.serialize())); - } - } - - public static class Deserializing extends JsonDeserializer { - - @Override - public ProfileKeyCommitment deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - try { - return new ProfileKeyCommitment(Base64.getDecoder().decode(p.getValueAsString())); - } catch (InvalidInputException e) { - throw new IOException(e); - } - } - } -} - diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialProfileResponse.java deleted file mode 100644 index 72f00fd78..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialProfileResponse.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialResponse; -import javax.annotation.Nullable; - -public class ProfileKeyCredentialProfileResponse extends CredentialProfileResponse { - - @JsonProperty - @JsonSerialize(using = ProfileKeyCredentialResponseAdapter.Serializing.class) - @JsonDeserialize(using = ProfileKeyCredentialResponseAdapter.Deserializing.class) - @Nullable - private ProfileKeyCredentialResponse credential; - - public ProfileKeyCredentialProfileResponse() { - } - - public ProfileKeyCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse, - @Nullable final ProfileKeyCredentialResponse credential) { - - super(versionedProfileResponse); - this.credential = credential; - } - - @Nullable - public ProfileKeyCredentialResponse getCredential() { - return credential; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialResponseAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialResponseAdapter.java deleted file mode 100644 index ff60178d7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCredentialResponseAdapter.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import java.io.IOException; -import java.util.Base64; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialResponse; - -public class ProfileKeyCredentialResponseAdapter { - - public static class Serializing extends JsonSerializer { - @Override - public void serialize(ProfileKeyCredentialResponse response, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) - throws IOException { - if (response == null) jsonGenerator.writeNull(); - else jsonGenerator.writeString(Base64.getEncoder().encodeToString(response.serialize())); - } - } - - public static class Deserializing extends JsonDeserializer { - @Override - public ProfileKeyCredentialResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) - throws IOException { - try { - return new ProfileKeyCredentialResponse(Base64.getDecoder().decode(jsonParser.getValueAsString())); - } catch (InvalidInputException e) { - throw new IOException(e); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java deleted file mode 100644 index b6c6dff8b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import javax.validation.constraints.NotEmpty; - -public record ProvisioningMessage(@NotEmpty String body) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PurchasableBadge.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/PurchasableBadge.java deleted file mode 100644 index 02819b853..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/PurchasableBadge.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.annotation.JsonFormat.Shape; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Duration; -import java.util.List; -import java.util.Objects; - -public class PurchasableBadge extends Badge { - private final Duration duration; - - @JsonCreator - public PurchasableBadge( - @JsonProperty("id") final String id, - @JsonProperty("category") final String category, - @JsonProperty("name") final String name, - @JsonProperty("description") final String description, - @JsonProperty("sprites6") final List sprites6, - @JsonProperty("svg") final String svg, - @JsonProperty("svgs") final List svgs, - @JsonProperty("duration") final Duration duration) { - super(id, category, name, description, sprites6, svg, svgs); - this.duration = duration; - } - - public PurchasableBadge(final Badge badge, final Duration duration) { - super( - badge.getId(), - badge.getCategory(), - badge.getName(), - badge.getDescription(), - badge.getSprites6(), - badge.getSvg(), - badge.getSvgs()); - this.duration = duration; - } - - @JsonFormat(shape = Shape.NUMBER_INT) - public Duration getDuration() { - return duration; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - if (!super.equals(o)) { - return false; - } - PurchasableBadge that = (PurchasableBadge) o; - return Objects.equals(duration, that.duration); - } - - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), duration); - } - - @Override - public String toString() { - return "PurchasableBadge{" + - "super=" + super.toString() + - ", duration=" + duration + - '}'; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RateLimitChallenge.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RateLimitChallenge.java deleted file mode 100644 index bb396ea42..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RateLimitChallenge.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import javax.validation.constraints.NotNull; - -public class RateLimitChallenge { - - @JsonProperty - @NotNull - private final String token; - - @JsonProperty - @NotNull - private final List options; - - @JsonCreator - public RateLimitChallenge(@JsonProperty("token") final String token, @JsonProperty("options") final List options) { - - this.token = token; - this.options = options; - } - - public String getToken() { - return token; - } - - public List getOptions() { - return options; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java deleted file mode 100644 index c4fa3a74c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; - -public class RedeemReceiptRequest { - - private final byte[] receiptCredentialPresentation; - private final boolean visible; - private final boolean primary; - - @JsonCreator - public RedeemReceiptRequest( - @JsonProperty("receiptCredentialPresentation") byte[] receiptCredentialPresentation, - @JsonProperty("visible") boolean visible, - @JsonProperty("primary") boolean primary) { - this.receiptCredentialPresentation = receiptCredentialPresentation; - this.visible = visible; - this.primary = primary; - } - - @NotEmpty - public byte[] getReceiptCredentialPresentation() { - return receiptCredentialPresentation; - } - - public boolean isVisible() { - return visible; - } - - public boolean isPrimary() { - return primary; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLock.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLock.java deleted file mode 100644 index f08ecc7c7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLock.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.Size; - -public class RegistrationLock { - - @JsonProperty - @Size(min=64, max=64) - @NotEmpty - private String registrationLock; - - public RegistrationLock() {} - - @VisibleForTesting - public RegistrationLock(String registrationLock) { - this.registrationLock = registrationLock; - } - - public String getRegistrationLock() { - return registrationLock; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java deleted file mode 100644 index 0872a8649..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; - -public record RegistrationLockFailure(long timeRemaining, ExternalServiceCredentials backupCredentials) { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationRequest.java deleted file mode 100644 index 8c15abc93..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; - -public record RegistrationRequest(String sessionId, - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) byte[] recoveryPassword, - @NotNull @Valid AccountAttributes accountAttributes, - boolean skipDeviceTransfer) implements PhoneVerificationRequest { - - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationSession.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationSession.java deleted file mode 100644 index d7e05bfc5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationSession.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -public record RegistrationSession(String number, boolean verified) { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashRequest.java deleted file mode 100644 index be37a9197..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.controllers.AccountController; -import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; -import java.util.List; - -public record ReserveUsernameHashRequest( - @NotNull - @Valid - @Size(min=1, max=AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH) - @JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class) - @JsonDeserialize(contentUsing = ByteArrayBase64UrlAdapter.Deserializing.class) - List usernameHashes -) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashResponse.java deleted file mode 100644 index b04b22efd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.controllers.AccountController; -import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; -import org.whispersystems.textsecuregcm.util.ExactlySize; -import java.util.UUID; - -public record ReserveUsernameHashResponse( - @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) - @ExactlySize(AccountController.USERNAME_HASH_LENGTH) - byte[] usernameHash -) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SelfBadge.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SelfBadge.java deleted file mode 100644 index 20593a645..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SelfBadge.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Instant; -import java.util.List; -import java.util.Objects; - -/** - * Extension of the Badge object returned when asking for one's own badges. - */ -public class SelfBadge extends Badge { - private final Instant expiration; - private final boolean visible; - - public SelfBadge( - @JsonProperty("id") final String id, - @JsonProperty("category") final String category, - @JsonProperty("name") final String name, - @JsonProperty("description") final String description, - @JsonProperty("sprites6") final List sprites6, - @JsonProperty("svg") final String svg, - @JsonProperty("svgs") final List svgs, - @JsonProperty("expiration") final Instant expiration, - @JsonProperty("visible") final boolean visible) { - super(id, category, name, description, sprites6, svg, svgs); - this.expiration = expiration; - this.visible = visible; - } - - public Instant getExpiration() { - return expiration; - } - - public boolean isVisible() { - return visible; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - if (!super.equals(o)) { - return false; - } - SelfBadge selfBadge = (SelfBadge) o; - return visible == selfBadge.visible && Objects.equals(expiration, selfBadge.expiration); - } - - @Override - public int hashCode() { - return Objects.hash(super.hashCode(), expiration, visible); - } - - @Override - public String toString() { - return "SelfBadge{" + - "super=" + super.toString() + - ", expiration=" + expiration + - ", visible=" + visible + - '}'; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMessageResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMessageResponse.java deleted file mode 100644 index 9c8649036..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMessageResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class SendMessageResponse { - - @JsonProperty - private boolean needsSync; - - public SendMessageResponse() {} - - public SendMessageResponse(boolean needsSync) { - this.needsSync = needsSync; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMultiRecipientMessageResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMultiRecipientMessageResponse.java deleted file mode 100644 index 62635d2ad..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMultiRecipientMessageResponse.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; -import java.util.List; -import java.util.UUID; - -public class SendMultiRecipientMessageResponse { - @JsonProperty - private List uuids404; - - public SendMultiRecipientMessageResponse() { - } - - public String toString() { - return "SendMultiRecipientMessageResponse(" + uuids404 + ")"; - } - - @VisibleForTesting - public List getUUIDs404() { - return this.uuids404; - } - - public SendMultiRecipientMessageResponse(final List uuids404) { - this.uuids404 = uuids404; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SignedPreKey.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SignedPreKey.java deleted file mode 100644 index 2b9e301f8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SignedPreKey.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.NotEmpty; - -public class SignedPreKey extends PreKey { - - @JsonProperty - @NotEmpty - private String signature; - - public SignedPreKey() {} - - public SignedPreKey(long keyId, String publicKey, String signature) { - super(keyId, publicKey); - this.signature = signature; - } - - public String getSignature() { - return signature; - } - - @Override - public boolean equals(Object object) { - if (object == null || !(object instanceof SignedPreKey)) return false; - SignedPreKey that = (SignedPreKey) object; - - if (signature == null) { - return super.equals(object) && that.signature == null; - } else { - return super.equals(object) && this.signature.equals(that.signature); - } - } - - @Override - public int hashCode() { - if (signature == null) { - return super.hashCode(); - } else { - return super.hashCode() ^ signature.hashCode(); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SpamReport.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/SpamReport.java deleted file mode 100644 index 185abbff6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/SpamReport.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; -import javax.annotation.Nullable; -import javax.validation.Valid; -import javax.validation.constraints.NotEmpty; - -public record SpamReport(@JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - @Nullable byte[] token) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/StaleDevices.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/StaleDevices.java deleted file mode 100644 index 98be70197..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/StaleDevices.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -public class StaleDevices { - - @JsonProperty - private List staleDevices; - - public StaleDevices() {} - - public String toString() { - return "StaleDevices(" + staleDevices + ")"; - } - - public StaleDevices(List staleDevices) { - this.staleDevices = staleDevices; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/StickerPackFormUploadAttributes.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/StickerPackFormUploadAttributes.java deleted file mode 100644 index bd0cd92e6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/StickerPackFormUploadAttributes.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -public class StickerPackFormUploadAttributes { - - @JsonProperty - private StickerPackFormUploadItem manifest; - - @JsonProperty - private List stickers; - - @JsonProperty - private String packId; - - public StickerPackFormUploadAttributes() {} - - public StickerPackFormUploadAttributes(String packId, StickerPackFormUploadItem manifest, List stickers) { - this.packId = packId; - this.manifest = manifest; - this.stickers = stickers; - } - - public StickerPackFormUploadItem getManifest() { - return manifest; - } - - public List getStickers() { - return stickers; - } - - public String getPackId() { - return packId; - } - - public static class StickerPackFormUploadItem { - @JsonProperty - private int id; - - @JsonProperty - private String key; - - @JsonProperty - private String credential; - - @JsonProperty - private String acl; - - @JsonProperty - private String algorithm; - - @JsonProperty - private String date; - - @JsonProperty - private String policy; - - @JsonProperty - private String signature; - - public StickerPackFormUploadItem() {} - - public StickerPackFormUploadItem(int id, String key, String credential, String acl, String algorithm, String date, String policy, String signature) { - this.key = key; - this.credential = credential; - this.acl = acl; - this.algorithm = algorithm; - this.date = date; - this.policy = policy; - this.signature = signature; - this.id = id; - } - - public String getKey() { - return key; - } - - public String getCredential() { - return credential; - } - - public String getAcl() { - return acl; - } - - public String getAlgorithm() { - return algorithm; - } - - public String getDate() { - return date; - } - - public String getPolicy() { - return policy; - } - - public String getSignature() { - return signature; - } - - public int getId() { - return id; - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UnregisteredEvent.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UnregisteredEvent.java deleted file mode 100644 index a7e58c3bd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UnregisteredEvent.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotEmpty; - -public class UnregisteredEvent { - - @JsonProperty - @NotEmpty - private String registrationId; - - @JsonProperty - private String canonicalId; - - @JsonProperty - @NotEmpty - private String number; - - @JsonProperty - @Min(1) - private int deviceId; - - @JsonProperty - private long timestamp; - - public String getRegistrationId() { - return registrationId; - } - - public String getCanonicalId() { - return canonicalId; - } - - public String getNumber() { - return number; - } - - public int getDeviceId() { - return deviceId; - } - - public long getTimestamp() { - return timestamp; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UnregisteredEventList.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UnregisteredEventList.java deleted file mode 100644 index 91896ab0b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UnregisteredEventList.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.LinkedList; -import java.util.List; - -public class UnregisteredEventList { - - @JsonProperty - private List devices; - - public List getDevices() { - if (devices == null) return new LinkedList<>(); - else return devices; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java deleted file mode 100644 index cf83f53ea..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserCapabilities.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import org.whispersystems.textsecuregcm.storage.Account; - -public record UserCapabilities( - @JsonProperty("gv1-migration") boolean gv1Migration, - boolean senderKey, - boolean announcementGroup, - boolean changeNumber, - boolean stories, - boolean giftBadges, - boolean paymentActivation, - boolean pni) { - - public static UserCapabilities createForAccount(Account account) { - return new UserCapabilities( - true, - account.isSenderKeySupported(), - account.isAnnouncementGroupSupported(), - account.isChangeNumberSupported(), - account.isStoriesSupported(), - account.isGiftBadgesSupported(), - - // Hardcode payment activation flag to false until all clients support the flow - false, - - // Although originally intended to indicate that clients support phone number identifiers, the scope of this - // flag has expanded to cover phone number privacy in general - account.isPniSupported()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfig.java deleted file mode 100644 index 276607cdd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfig.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class UserRemoteConfig { - - @JsonProperty - private String name; - - @JsonProperty - private boolean enabled; - - @JsonProperty - private String value; - - public UserRemoteConfig() {} - - public UserRemoteConfig(String name, boolean enabled, String value) { - this.name = name; - this.enabled = enabled; - this.value = value; - } - - public String getName() { - return name; - } - - public boolean isEnabled() { - return enabled; - } - - public String getValue() { - return value; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfigList.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfigList.java deleted file mode 100644 index 6fba91364..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfigList.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -public class UserRemoteConfigList { - - @JsonProperty - private List config; - - public UserRemoteConfigList() {} - - public UserRemoteConfigList(List config) { - this.config = config; - } - - public List getConfig() { - return config; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UsernameHashResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/UsernameHashResponse.java deleted file mode 100644 index 2a7653181..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/UsernameHashResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.controllers.AccountController; -import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; -import org.whispersystems.textsecuregcm.util.ExactlySize; -import javax.validation.Valid; - -public record UsernameHashResponse( - @Valid - @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) - @ExactlySize(AccountController.USERNAME_HASH_LENGTH) - byte[] usernameHash -) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/entities/VersionedProfileResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/entities/VersionedProfileResponse.java deleted file mode 100644 index 30e71a101..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/entities/VersionedProfileResponse.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonUnwrapped; - -public class VersionedProfileResponse { - - @JsonUnwrapped - private BaseProfileResponse baseProfileResponse; - - @JsonProperty - private String name; - - @JsonProperty - private String about; - - @JsonProperty - private String aboutEmoji; - - @JsonProperty - private String avatar; - - @JsonProperty - private String paymentAddress; - - public VersionedProfileResponse() { - } - - public VersionedProfileResponse(final BaseProfileResponse baseProfileResponse, - final String name, - final String about, - final String aboutEmoji, - final String avatar, - final String paymentAddress) { - - this.baseProfileResponse = baseProfileResponse; - this.name = name; - this.about = about; - this.aboutEmoji = aboutEmoji; - this.avatar = avatar; - this.paymentAddress = paymentAddress; - } - - public BaseProfileResponse getBaseProfileResponse() { - return baseProfileResponse; - } - - public String getName() { - return name; - } - - public String getAbout() { - return about; - } - - public String getAboutEmoji() { - return aboutEmoji; - } - - public String getAvatar() { - return avatar; - } - - public String getPaymentAddress() { - return paymentAddress; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/Experiment.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/Experiment.java deleted file mode 100644 index 5bd1d38c5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/Experiment.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.experiment; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * An experiment compares the results of two operations and records metrics to assess how frequently they match. - */ -public class Experiment { - - private final String name; - - private final Timer matchTimer; - private final Timer errorTimer; - - private final Timer bothPresentMismatchTimer; - private final Timer controlNullMismatchTimer; - private final Timer experimentNullMismatchTimer; - - private static final String OUTCOME_TAG = "outcome"; - private static final String MATCH_OUTCOME = "match"; - private static final String MISMATCH_OUTCOME = "mismatch"; - private static final String ERROR_OUTCOME = "error"; - - private static final String MISMATCH_TYPE_TAG = "mismatchType"; - private static final String BOTH_PRESENT_MISMATCH = "bothPresent"; - private static final String CONTROL_NULL_MISMATCH = "controlResultNull"; - private static final String EXPERIMENT_NULL_MISMATCH = "experimentResultNull"; - - private static final Logger log = LoggerFactory.getLogger(Experiment.class); - - public Experiment(final String... names) { - this(name(Experiment.class, names), - Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MATCH_OUTCOME), - Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, ERROR_OUTCOME), - Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MISMATCH_OUTCOME, MISMATCH_TYPE_TAG, - BOTH_PRESENT_MISMATCH), - Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MISMATCH_OUTCOME, MISMATCH_TYPE_TAG, - CONTROL_NULL_MISMATCH), - Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MISMATCH_OUTCOME, MISMATCH_TYPE_TAG, - EXPERIMENT_NULL_MISMATCH)); - } - - @VisibleForTesting - Experiment(final String name, final Timer matchTimer, final Timer errorTimer, final Timer bothPresentMismatchTimer, - final Timer controlNullMismatchTimer, final Timer experimentNullMismatchTimer) { - this.name = name; - - this.matchTimer = matchTimer; - this.errorTimer = errorTimer; - - this.bothPresentMismatchTimer = bothPresentMismatchTimer; - this.controlNullMismatchTimer = controlNullMismatchTimer; - this.experimentNullMismatchTimer = experimentNullMismatchTimer; - } - - public void compareFutureResult(final T expected, final CompletionStage experimentStage) { - final long startNanos = System.nanoTime(); - - experimentStage.whenComplete((actual, cause) -> { - final long durationNanos = System.nanoTime() - startNanos; - - if (cause != null) { - recordError(cause, durationNanos); - } else { - recordResult(expected, actual, durationNanos); - } - }); - } - - public void compareSupplierResult(final T expected, final Supplier experimentSupplier) { - final long startNanos = System.nanoTime(); - - try { - final T result = experimentSupplier.get(); - - recordResult(expected, result, System.nanoTime() - startNanos); - } catch (final Exception e) { - recordError(e, System.nanoTime() - startNanos); - } - } - - public void compareSupplierResultAsync(final T expected, final Supplier experimentSupplier, - final Executor executor) { - final long startNanos = System.nanoTime(); - - try { - compareFutureResult(expected, CompletableFuture.supplyAsync(experimentSupplier, executor)); - } catch (final Exception e) { - recordError(e, System.nanoTime() - startNanos); - } - } - - private void recordError(final Throwable cause, final long durationNanos) { - log.warn("Experiment {} threw an exception.", name, cause); - errorTimer.record(durationNanos, TimeUnit.NANOSECONDS); - } - - @VisibleForTesting - void recordResult(final T expected, final T actual, final long durationNanos) { - if (expected instanceof Optional && actual instanceof Optional) { - recordResult(((Optional) expected).orElse(null), ((Optional) actual).orElse(null), durationNanos); - } else { - final Timer Timer; - - if (Objects.equals(expected, actual)) { - Timer = matchTimer; - } else if (expected == null) { - Timer = controlNullMismatchTimer; - } else if (actual == null) { - Timer = experimentNullMismatchTimer; - } else { - Timer = bothPresentMismatchTimer; - } - - Timer.record(durationNanos, TimeUnit.NANOSECONDS); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java deleted file mode 100644 index e9f09c354..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.experiment; - -import java.util.Optional; -import java.util.UUID; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPreRegistrationExperimentEnrollmentConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.Util; - -public class ExperimentEnrollmentManager { - - private final DynamicConfigurationManager dynamicConfigurationManager; - - public ExperimentEnrollmentManager(final DynamicConfigurationManager dynamicConfigurationManager) { - this.dynamicConfigurationManager = dynamicConfigurationManager; - } - - public boolean isEnrolled(final UUID accountUuid, final String experimentName) { - - final Optional maybeConfiguration = dynamicConfigurationManager - .getConfiguration().getExperimentEnrollmentConfiguration(experimentName); - - return maybeConfiguration.map(config -> { - - if (config.getEnrolledUuids().contains(accountUuid)) { - return true; - } - - return isEnrolled(accountUuid, config.getEnrollmentPercentage(), experimentName); - - }).orElse(false); - } - - public boolean isEnrolled(final String e164, final String experimentName) { - - final Optional maybeConfiguration = dynamicConfigurationManager - .getConfiguration().getPreRegistrationEnrollmentConfiguration(experimentName); - - return maybeConfiguration.map(config -> { - - if (config.getEnrolledE164s().contains(e164)) { - return true; - } - - if (config.getExcludedE164s().contains(e164)) { - return false; - } - - { - final String countryCode = Util.getCountryCode(e164); - - if (config.getIncludedCountryCodes().contains(countryCode)) { - return true; - } - - if (config.getExcludedCountryCodes().contains(countryCode)) { - return false; - } - } - - return isEnrolled(e164, config.getEnrollmentPercentage(), experimentName); - - }).orElse(false); - } - - private boolean isEnrolled(final Object entity, final int enrollmentPercentage, final String experimentName) { - final int enrollmentHash = ((entity.hashCode() ^ experimentName.hashCode()) & Integer.MAX_VALUE) % 100; - - return enrollmentHash < enrollmentPercentage; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java deleted file mode 100644 index 5d185657a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright 2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.filters; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.google.common.net.HttpHeaders; -import com.vdurmont.semver4j.Semver; -import io.micrometer.core.instrument.Metrics; -import java.io.IOException; -import java.util.Map; -import java.util.Set; -import javax.servlet.Filter; -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; - -/** - * The remote deprecation filter rejects traffic from clients older than a configured minimum - * version. It may optionally also reject traffic from clients with unrecognized User-Agent strings. - * If a client platform does not have a configured minimum version, all traffic from that client - * platform is allowed. - */ -public class RemoteDeprecationFilter implements Filter { - - private final DynamicConfigurationManager dynamicConfigurationManager; - - private static final String DEPRECATED_CLIENT_COUNTER_NAME = name(RemoteDeprecationFilter.class, "deprecated"); - private static final String PENDING_DEPRECATION_COUNTER_NAME = name(RemoteDeprecationFilter.class, "pendingDeprecation"); - private static final String PLATFORM_TAG = "platform"; - private static final String REASON_TAG_NAME = "reason"; - private static final String EXPIRED_CLIENT_REASON = "expired"; - private static final String BLOCKED_CLIENT_REASON = "blocked"; - private static final String UNRECOGNIZED_UA_REASON = "unrecognized_user_agent"; - - public RemoteDeprecationFilter(final DynamicConfigurationManager dynamicConfigurationManager) { - this.dynamicConfigurationManager = dynamicConfigurationManager; - } - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - final DynamicRemoteDeprecationConfiguration configuration = dynamicConfigurationManager - .getConfiguration().getRemoteDeprecationConfiguration(); - - final Map minimumVersionsByPlatform = configuration.getMinimumVersions(); - final Map versionsPendingDeprecationByPlatform = configuration.getVersionsPendingDeprecation(); - final Map> blockedVersionsByPlatform = configuration.getBlockedVersions(); - final Map> versionsPendingBlockByPlatform = configuration.getVersionsPendingBlock(); - final boolean allowUnrecognizedUserAgents = configuration.isUnrecognizedUserAgentAllowed(); - - boolean shouldBlock = false; - - try { - final String userAgentString = ((HttpServletRequest) request).getHeader(HttpHeaders.USER_AGENT); - final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString); - - if (blockedVersionsByPlatform.containsKey(userAgent.getPlatform())) { - if (blockedVersionsByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) { - recordDeprecation(userAgent, BLOCKED_CLIENT_REASON); - shouldBlock = true; - } - } - - if (minimumVersionsByPlatform.containsKey(userAgent.getPlatform())) { - if (userAgent.getVersion().isLowerThan(minimumVersionsByPlatform.get(userAgent.getPlatform()))) { - recordDeprecation(userAgent, EXPIRED_CLIENT_REASON); - shouldBlock = true; - } - } - - if (versionsPendingBlockByPlatform.containsKey(userAgent.getPlatform())) { - if (versionsPendingBlockByPlatform.get(userAgent.getPlatform()).contains(userAgent.getVersion())) { - recordPendingDeprecation(userAgent, BLOCKED_CLIENT_REASON); - } - } - - if (versionsPendingDeprecationByPlatform.containsKey(userAgent.getPlatform())) { - if (userAgent.getVersion().isLowerThan(versionsPendingDeprecationByPlatform.get(userAgent.getPlatform()))) { - recordPendingDeprecation(userAgent, EXPIRED_CLIENT_REASON); - } - } - } catch (final UnrecognizedUserAgentException e) { - if (!allowUnrecognizedUserAgents) { - recordDeprecation(null, UNRECOGNIZED_UA_REASON); - shouldBlock = true; - } - } - - if (shouldBlock) { - ((HttpServletResponse) response).sendError(499); - } else { - chain.doFilter(request, response); - } - } - - private void recordDeprecation(final UserAgent userAgent, final String reason) { - Metrics.counter(DEPRECATED_CLIENT_COUNTER_NAME, - PLATFORM_TAG, userAgent != null ? userAgent.getPlatform().name().toLowerCase() : "unrecognized", - REASON_TAG_NAME, reason).increment(); - } - - private void recordPendingDeprecation(final UserAgent userAgent, final String reason) { - Metrics.counter(PENDING_DEPRECATION_COUNTER_NAME, - PLATFORM_TAG, userAgent.getPlatform().name().toLowerCase(), - REASON_TAG_NAME, reason).increment(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilter.java deleted file mode 100644 index 078fb6a1b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilter.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.filters; - -import static com.codahale.metrics.MetricRegistry.name; -import static java.util.Objects.requireNonNull; - -import com.google.common.net.HttpHeaders; -import com.google.common.net.InetAddresses; -import io.micrometer.core.instrument.Metrics; -import java.io.IOException; -import java.net.Inet4Address; -import java.net.Inet6Address; -import java.net.InetAddress; -import javax.annotation.Nonnull; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.container.ContainerRequestFilter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.metrics.TrafficSource; -import org.whispersystems.textsecuregcm.util.HeaderUtils; - -public class RequestStatisticsFilter implements ContainerRequestFilter { - - private static final Logger logger = LoggerFactory.getLogger(RequestStatisticsFilter.class); - - private static final String CONTENT_LENGTH_DISTRIBUTION_NAME = name(RequestStatisticsFilter.class, "contentLength"); - - private static final String IP_VERSION_METRIC = name(RequestStatisticsFilter.class, "ipVersion"); - - private static final String TRAFFIC_SOURCE_TAG = "trafficSource"; - - private static final String IP_VERSION_TAG = "ipVersion"; - - @Nonnull - private final String trafficSourceTag; - - - public RequestStatisticsFilter(@Nonnull final TrafficSource trafficeSource) { - this.trafficSourceTag = requireNonNull(trafficeSource).name().toLowerCase(); - } - - @Override - public void filter(final ContainerRequestContext requestContext) throws IOException { - try { - Metrics.summary(CONTENT_LENGTH_DISTRIBUTION_NAME, TRAFFIC_SOURCE_TAG, trafficSourceTag) - .record(requestContext.getLength()); - Metrics.counter(IP_VERSION_METRIC, TRAFFIC_SOURCE_TAG, trafficSourceTag, IP_VERSION_TAG, resolveIpVersion(requestContext)) - .increment(); - } catch (final Exception e) { - logger.warn("Error recording request statistics", e); - } - } - - @Nonnull - private static String resolveIpVersion(@Nonnull final ContainerRequestContext ctx) { - return HeaderUtils.getMostRecentProxy(ctx.getHeaderString(HttpHeaders.X_FORWARDED_FOR)) - .map(ipString -> { - try { - //noinspection UnstableApiUsage - final InetAddress addr = InetAddresses.forString(ipString); - if (addr instanceof Inet4Address) { - return "IPv4"; - } - if (addr instanceof Inet6Address) { - return "IPv6"; - } - } catch (IllegalArgumentException e) { - // ignore illegal argument exception - } - return null; - }) - .orElse("unresolved"); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilter.java deleted file mode 100644 index 5d9553ccb..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilter.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.filters; - -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.container.ContainerResponseContext; -import javax.ws.rs.container.ContainerResponseFilter; -import org.whispersystems.textsecuregcm.util.HeaderUtils; - -/** - * Injects a timestamp header into all outbound responses. - */ -public class TimestampResponseFilter implements ContainerResponseFilter { - - @Override - public void filter(final ContainerRequestContext requestContext, final ContainerResponseContext responseContext) { - responseContext.getHeaders().add(HeaderUtils.TIMESTAMP_HEADER, System.currentTimeMillis()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequest.java b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequest.java deleted file mode 100644 index c4a94686b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequest.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.gcp; - -import javax.annotation.Nonnull; - -public class CanonicalRequest { - - @Nonnull - private final String canonicalRequest; - - @Nonnull - private final String resourcePath; - - @Nonnull - private final String canonicalQuery; - - @Nonnull - private final String activeDatetime; - - @Nonnull - private final String credentialScope; - - @Nonnull - private final String domain; - - private final int maxSizeInBytes; - - public CanonicalRequest(@Nonnull String canonicalRequest, @Nonnull String resourcePath, @Nonnull String canonicalQuery, @Nonnull String activeDatetime, @Nonnull String credentialScope, @Nonnull String domain, int maxSizeInBytes) { - this.canonicalRequest = canonicalRequest; - this.resourcePath = resourcePath; - this.canonicalQuery = canonicalQuery; - this.activeDatetime = activeDatetime; - this.credentialScope = credentialScope; - this.domain = domain; - this.maxSizeInBytes = maxSizeInBytes; - } - - @Nonnull - String getCanonicalRequest() { - return canonicalRequest; - } - - @Nonnull - public String getResourcePath() { - return resourcePath; - } - - @Nonnull - public String getCanonicalQuery() { - return canonicalQuery; - } - - @Nonnull - String getActiveDatetime() { - return activeDatetime; - } - - @Nonnull - String getCredentialScope() { - return credentialScope; - } - - @Nonnull - public String getDomain() { - return domain; - } - - public int getMaxSizeInBytes() { - return maxSizeInBytes; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestGenerator.java deleted file mode 100644 index d530bf887..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestGenerator.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.gcp; - -import io.dropwizard.util.Strings; - -import javax.annotation.Nonnull; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.temporal.ChronoUnit; -import java.util.Locale; - -public class CanonicalRequestGenerator { - private static final DateTimeFormatter SIMPLE_UTC_DATE = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.US).withZone(ZoneOffset.UTC); - private static final DateTimeFormatter SIMPLE_UTC_DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.US).withZone(ZoneOffset.UTC); - - @Nonnull - private final String domain; - - @Nonnull - private final String email; - - private final int maxSizeBytes; - - @Nonnull - private final String pathPrefix; - - public CanonicalRequestGenerator(@Nonnull String domain, @Nonnull String email, int maxSizeBytes, @Nonnull String pathPrefix) { - this.domain = domain; - this.email = email; - this.maxSizeBytes = maxSizeBytes; - this.pathPrefix = pathPrefix; - } - - public CanonicalRequest createFor(@Nonnull final String key, @Nonnull final ZonedDateTime now) { - final StringBuilder result = new StringBuilder("POST\n"); - - final StringBuilder resourcePathBuilder = new StringBuilder(); - if (!Strings.isNullOrEmpty(pathPrefix)) { - resourcePathBuilder.append(pathPrefix); - } - resourcePathBuilder.append('/').append(URLEncoder.encode(key, StandardCharsets.UTF_8)); - final String resourcePath = resourcePathBuilder.toString(); - result.append(resourcePath).append('\n'); - - final String activeDatetime = SIMPLE_UTC_DATE_TIME.format(now); - final String canonicalQuery = "X-Goog-Algorithm=GOOG4-RSA-SHA256" + - "&X-Goog-Credential=" + URLEncoder.encode(makeCredential(email, now), StandardCharsets.UTF_8) + - "&X-Goog-Date=" + URLEncoder.encode(activeDatetime, StandardCharsets.UTF_8) + - "&X-Goog-Expires=" + Duration.of(25, ChronoUnit.HOURS).toSeconds() + - "&X-Goog-SignedHeaders=host%3Bx-goog-content-length-range%3Bx-goog-resumable"; - result.append(canonicalQuery).append('\n'); - - result.append("host:").append(domain).append('\n'); - result.append("x-goog-content-length-range:1,").append(maxSizeBytes).append('\n'); - result.append("x-goog-resumable:start\n"); - result.append('\n'); - - result.append("host;x-goog-content-length-range;x-goog-resumable\n"); - - result.append("UNSIGNED-PAYLOAD"); - - return new CanonicalRequest(result.toString(), resourcePath, canonicalQuery, activeDatetime, makeCredentialScope(now), domain, maxSizeBytes); - } - - private String makeCredentialScope(@Nonnull ZonedDateTime now) { - return SIMPLE_UTC_DATE.format(now) + "/auto/storage/goog4_request"; - } - - private String makeCredential(@Nonnull String email, @Nonnull ZonedDateTime now) { - return email + '/' + makeCredentialScope(now); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestSigner.java b/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestSigner.java deleted file mode 100644 index 331336d41..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestSigner.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.gcp; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.KeyFactory; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.Signature; -import java.security.SignatureException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.Base64; -import java.util.HexFormat; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.annotation.Nonnull; - -public class CanonicalRequestSigner { - - @Nonnull - private final PrivateKey rsaSigningKey; - - private static final Pattern PRIVATE_KEY_PATTERN = - Pattern.compile("^-+BEGIN PRIVATE KEY-+\\s*(.+)\\n-+END PRIVATE KEY-+\\s*$", Pattern.DOTALL); - - public CanonicalRequestSigner(@Nonnull String rsaSigningKey) throws IOException, InvalidKeyException, InvalidKeySpecException { - this.rsaSigningKey = initializeRsaSigningKey(rsaSigningKey); - } - - public String sign(@Nonnull CanonicalRequest canonicalRequest) { - return sign(makeStringToSign(canonicalRequest)); - } - - private String makeStringToSign(@Nonnull final CanonicalRequest canonicalRequest) { - final StringBuilder result = new StringBuilder("GOOG4-RSA-SHA256\n"); - - result.append(canonicalRequest.getActiveDatetime()).append('\n'); - - result.append(canonicalRequest.getCredentialScope()).append('\n'); - - final MessageDigest sha256; - try { - sha256 = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - sha256.update(canonicalRequest.getCanonicalRequest().getBytes(StandardCharsets.UTF_8)); - result.append(HexFormat.of().formatHex(sha256.digest())); - - return result.toString(); - } - - private String sign(@Nonnull String stringToSign) { - final byte[] signature; - try { - final Signature sha256rsa = Signature.getInstance("SHA256WITHRSA"); - sha256rsa.initSign(rsaSigningKey); - sha256rsa.update(stringToSign.getBytes(StandardCharsets.UTF_8)); - signature = sha256rsa.sign(); - } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { - throw new AssertionError(e); - } - return HexFormat.of().formatHex(signature); - } - - private static PrivateKey initializeRsaSigningKey(String rsaSigningKey) throws IOException, InvalidKeyException, InvalidKeySpecException { - final Matcher matcher = PRIVATE_KEY_PATTERN.matcher(rsaSigningKey); - - if (matcher.matches()) { - try { - final KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getMimeDecoder().decode(matcher.group(1))); - final PrivateKey key = keyFactory.generatePrivate(keySpec); - - testKeyIsValidForSigning(key); - return key; - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - } - - throw new IOException("Invalid RSA key"); - } - - private static void testKeyIsValidForSigning(PrivateKey key) throws InvalidKeyException { - final Signature sha256rsa; - try { - sha256rsa = Signature.getInstance("SHA256WITHRSA"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - sha256rsa.initSign(key); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClient.java deleted file mode 100644 index 976112b7a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClient.java +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.http; - -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import io.github.resilience4j.circuitbreaker.CircuitBreaker; -import io.github.resilience4j.retry.Retry; -import io.github.resilience4j.retry.RetryConfig; -import org.glassfish.jersey.SslConfigurator; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; -import org.whispersystems.textsecuregcm.util.CertificateUtil; -import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil; -import org.whispersystems.textsecuregcm.util.Constants; - -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.security.KeyStore; -import java.security.cert.CertificateException; -import java.time.Duration; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.function.Supplier; - -public class FaultTolerantHttpClient { - - private final HttpClient httpClient; - private final ScheduledExecutorService retryExecutor; - private final Retry retry; - private final CircuitBreaker breaker; - - public static final String SECURITY_PROTOCOL_TLS_1_2 = "TLSv1.2"; - public static final String SECURITY_PROTOCOL_TLS_1_3 = "TLSv1.3"; - - public static Builder newBuilder() { - return new Builder(); - } - - private FaultTolerantHttpClient(String name, HttpClient httpClient, RetryConfiguration retryConfiguration, CircuitBreakerConfiguration circuitBreakerConfiguration) { - this.httpClient = httpClient; - this.retryExecutor = Executors.newSingleThreadScheduledExecutor(); - this.breaker = CircuitBreaker.of(name + "-breaker", circuitBreakerConfiguration.toCircuitBreakerConfig()); - - MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - CircuitBreakerUtil.registerMetrics(metricRegistry, breaker, FaultTolerantHttpClient.class); - - if (retryConfiguration != null) { - RetryConfig retryConfig = retryConfiguration.toRetryConfigBuilder().retryOnResult(o -> o.statusCode() >= 500).build(); - this.retry = Retry.of(name + "-retry", retryConfig); - CircuitBreakerUtil.registerMetrics(metricRegistry, retry, FaultTolerantHttpClient.class); - } else { - this.retry = null; - } - } - - public CompletableFuture> sendAsync(HttpRequest request, HttpResponse.BodyHandler bodyHandler) { - Supplier>> asyncRequest = sendAsync(httpClient, request, bodyHandler); - - if (retry != null) { - return breaker.executeCompletionStage(retryableCompletionStage(asyncRequest)).toCompletableFuture(); - } else { - return breaker.executeCompletionStage(asyncRequest).toCompletableFuture(); - } - } - - private Supplier> retryableCompletionStage(Supplier> supplier) { - return () -> retry.executeCompletionStage(retryExecutor, supplier); - } - - private Supplier>> sendAsync(HttpClient client, HttpRequest request, HttpResponse.BodyHandler bodyHandler) { - return () -> client.sendAsync(request, bodyHandler); - } - - public static class Builder { - - - private HttpClient.Version version = HttpClient.Version.HTTP_2; - private HttpClient.Redirect redirect = HttpClient.Redirect.NEVER; - private Duration connectTimeout = Duration.ofSeconds(10); - - private String name; - private Executor executor; - private KeyStore trustStore; - private String securityProtocol = SECURITY_PROTOCOL_TLS_1_2; - private RetryConfiguration retryConfiguration; - private CircuitBreakerConfiguration circuitBreakerConfiguration; - - private Builder() {} - - public Builder withName(String name) { - this.name = name; - return this; - } - - public Builder withVersion(HttpClient.Version version) { - this.version = version; - return this; - } - - public Builder withRedirect(HttpClient.Redirect redirect) { - this.redirect = redirect; - return this; - } - - public Builder withExecutor(Executor executor) { - this.executor = executor; - return this; - } - - public Builder withConnectTimeout(Duration connectTimeout) { - this.connectTimeout = connectTimeout; - return this; - } - - public Builder withRetry(RetryConfiguration retryConfiguration) { - this.retryConfiguration = retryConfiguration; - return this; - } - - public Builder withCircuitBreaker(CircuitBreakerConfiguration circuitBreakerConfiguration) { - this.circuitBreakerConfiguration = circuitBreakerConfiguration; - return this; - } - - public Builder withSecurityProtocol(final String securityProtocol) { - this.securityProtocol = securityProtocol; - return this; - } - - public Builder withTrustedServerCertificates(final String... certificatePem) throws CertificateException { - this.trustStore = CertificateUtil.buildKeyStoreForPem(certificatePem); - return this; - } - - public FaultTolerantHttpClient build() { - if (this.circuitBreakerConfiguration == null || this.name == null || this.executor == null) { - throw new IllegalArgumentException("Must specify circuit breaker config, name, and executor"); - } - - final HttpClient.Builder builder = HttpClient.newBuilder() - .connectTimeout(connectTimeout) - .followRedirects(redirect) - .version(version) - .executor(executor); - - final SslConfigurator sslConfigurator = SslConfigurator.newInstance().securityProtocol(securityProtocol); - - if (this.trustStore != null) { - sslConfigurator.trustStore(trustStore); - } - - builder.sslContext(sslConfigurator.createSSLContext()); - - return new FaultTolerantHttpClient(name, builder.build(), retryConfiguration, circuitBreakerConfiguration); - } - - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/http/FormDataBodyPublisher.java b/service/src/main/java/org/whispersystems/textsecuregcm/http/FormDataBodyPublisher.java deleted file mode 100644 index b2935e499..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/http/FormDataBodyPublisher.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.http; - -import java.net.URLEncoder; -import java.net.http.HttpRequest; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -public class FormDataBodyPublisher { - - public static HttpRequest.BodyPublisher of(Map data) { - StringBuilder builder = new StringBuilder(); - - for (Map.Entry entry : data.entrySet()) { - if (builder.length() > 0) { - builder.append("&"); - } - - builder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); - builder.append("="); - builder.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); - } - - return HttpRequest.BodyPublishers.ofString(builder.toString()); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/DynamicRateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/DynamicRateLimiters.java deleted file mode 100644 index 9ee383f1d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/DynamicRateLimiters.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiFunction; -import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -public class DynamicRateLimiters { - - private final FaultTolerantRedisCluster cacheCluster; - private final DynamicConfigurationManager dynamicConfigurationManager; - - private final AtomicReference rateLimitResetLimiter; - private final AtomicReference recaptchaChallengeAttemptLimiter; - private final AtomicReference recaptchaChallengeSuccessLimiter; - private final AtomicReference pushChallengeAttemptLimiter; - private final AtomicReference pushChallengeSuccessLimiter; - - public DynamicRateLimiters(final FaultTolerantRedisCluster rateLimitCluster, - final DynamicConfigurationManager dynamicConfigurationManager) { - - this.cacheCluster = rateLimitCluster; - this.dynamicConfigurationManager = dynamicConfigurationManager; - - this.rateLimitResetLimiter = new AtomicReference<>( - createRateLimitResetLimiter(this.cacheCluster, - this.dynamicConfigurationManager.getConfiguration().getLimits().getRateLimitReset())); - - this.recaptchaChallengeAttemptLimiter = new AtomicReference<>(createRecaptchaChallengeAttemptLimiter( - this.cacheCluster, - this.dynamicConfigurationManager.getConfiguration().getLimits().getRecaptchaChallengeAttempt())); - - this.recaptchaChallengeSuccessLimiter = new AtomicReference<>(createRecaptchaChallengeSuccessLimiter( - this.cacheCluster, - this.dynamicConfigurationManager.getConfiguration().getLimits().getRecaptchaChallengeSuccess())); - - this.pushChallengeAttemptLimiter = new AtomicReference<>(createPushChallengeAttemptLimiter(this.cacheCluster, - this.dynamicConfigurationManager.getConfiguration().getLimits().getPushChallengeAttempt())); - - this.pushChallengeSuccessLimiter = new AtomicReference<>(createPushChallengeSuccessLimiter(this.cacheCluster, - this.dynamicConfigurationManager.getConfiguration().getLimits().getPushChallengeSuccess())); - } - - public RateLimiter getRateLimitResetLimiter() { - return updateAndGetRateLimiter( - rateLimitResetLimiter, - dynamicConfigurationManager.getConfiguration().getLimits().getRateLimitReset(), - this::createRateLimitResetLimiter); - } - - public RateLimiter getRecaptchaChallengeAttemptLimiter() { - return updateAndGetRateLimiter( - recaptchaChallengeAttemptLimiter, - dynamicConfigurationManager.getConfiguration().getLimits().getRecaptchaChallengeAttempt(), - this::createRecaptchaChallengeAttemptLimiter); - } - - public RateLimiter getRecaptchaChallengeSuccessLimiter() { - return updateAndGetRateLimiter( - recaptchaChallengeSuccessLimiter, - dynamicConfigurationManager.getConfiguration().getLimits().getRecaptchaChallengeSuccess(), - this::createRecaptchaChallengeSuccessLimiter); - } - - public RateLimiter getPushChallengeAttemptLimiter() { - return updateAndGetRateLimiter( - pushChallengeAttemptLimiter, - dynamicConfigurationManager.getConfiguration().getLimits().getPushChallengeAttempt(), - this::createPushChallengeAttemptLimiter); - } - - public RateLimiter getPushChallengeSuccessLimiter() { - return updateAndGetRateLimiter( - pushChallengeSuccessLimiter, - dynamicConfigurationManager.getConfiguration().getLimits().getPushChallengeSuccess(), - this::createPushChallengeSuccessLimiter); - } - - private RateLimiter updateAndGetRateLimiter(final AtomicReference rateLimiter, - RateLimitConfiguration currentConfiguration, - BiFunction rateLimitFactory) { - - return rateLimiter.updateAndGet(limiter -> { - if (limiter.hasConfiguration(currentConfiguration)) { - return limiter; - } else { - return rateLimitFactory.apply(cacheCluster, currentConfiguration); - } - }); - } - - public RateLimiter createRateLimitResetLimiter(FaultTolerantRedisCluster cacheCluster, - RateLimitConfiguration configuration) { - return createLimiter(cacheCluster, configuration, "rateLimitReset"); - } - - public RateLimiter createRecaptchaChallengeAttemptLimiter(FaultTolerantRedisCluster cacheCluster, - RateLimitConfiguration configuration) { - return createLimiter(cacheCluster, configuration, "recaptchaChallengeAttempt"); - } - - public RateLimiter createRecaptchaChallengeSuccessLimiter(FaultTolerantRedisCluster cacheCluster, - RateLimitConfiguration configuration) { - return createLimiter(cacheCluster, configuration, "recaptchaChallengeSuccess"); - } - - public RateLimiter createPushChallengeAttemptLimiter(FaultTolerantRedisCluster cacheCluster, - RateLimitConfiguration configuration) { - return createLimiter(cacheCluster, configuration, "pushChallengeAttempt"); - } - - public RateLimiter createPushChallengeSuccessLimiter(FaultTolerantRedisCluster cacheCluster, - RateLimitConfiguration configuration) { - return createLimiter(cacheCluster, configuration, "pushChallengeSuccess"); - } - - private RateLimiter createLimiter(FaultTolerantRedisCluster cacheCluster, RateLimitConfiguration configuration, - String name) { - return new RateLimiter(cacheCluster, name, - configuration.getBucketSize(), - configuration.getLeakRatePerMinute()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/LeakyBucket.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/LeakyBucket.java deleted file mode 100644 index 51b60ea8d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/LeakyBucket.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.limits; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.io.IOException; -import java.time.Duration; - -public class LeakyBucket { - - private final int bucketSize; - private final double leakRatePerMillis; - - private int spaceRemaining; - private long lastUpdateTimeMillis; - - public LeakyBucket(int bucketSize, double leakRatePerMillis) { - this(bucketSize, leakRatePerMillis, bucketSize, System.currentTimeMillis()); - } - - private LeakyBucket(int bucketSize, double leakRatePerMillis, int spaceRemaining, long lastUpdateTimeMillis) { - this.bucketSize = bucketSize; - this.leakRatePerMillis = leakRatePerMillis; - this.spaceRemaining = spaceRemaining; - this.lastUpdateTimeMillis = lastUpdateTimeMillis; - } - - public boolean add(int amount) { - this.spaceRemaining = getUpdatedSpaceRemaining(); - this.lastUpdateTimeMillis = System.currentTimeMillis(); - - if (this.spaceRemaining >= amount) { - this.spaceRemaining -= amount; - return true; - } else { - return false; - } - } - - private int getUpdatedSpaceRemaining() { - long elapsedTime = System.currentTimeMillis() - this.lastUpdateTimeMillis; - - return Math.min(this.bucketSize, - (int)Math.floor(this.spaceRemaining + (elapsedTime * this.leakRatePerMillis))); - } - - public Duration getTimeUntilSpaceAvailable(int amount) { - int currentSpaceRemaining = getUpdatedSpaceRemaining(); - if (currentSpaceRemaining >= amount) { - return Duration.ZERO; - } else if (amount > this.bucketSize) { - // This shouldn't happen today but if so we should bubble this to the clients somehow - throw new IllegalArgumentException("Requested permits exceed maximum bucket size"); - } else { - return Duration.ofMillis((long)Math.ceil((double)(amount - currentSpaceRemaining) / this.leakRatePerMillis)); - } - } - - public String serialize(ObjectMapper mapper) throws JsonProcessingException { - return mapper.writeValueAsString(new LeakyBucketEntity(bucketSize, leakRatePerMillis, spaceRemaining, lastUpdateTimeMillis)); - } - - public static LeakyBucket fromSerialized(ObjectMapper mapper, String serialized) throws IOException { - LeakyBucketEntity entity = mapper.readValue(serialized, LeakyBucketEntity.class); - - return new LeakyBucket(entity.bucketSize, entity.leakRatePerMillis, - entity.spaceRemaining, entity.lastUpdateTimeMillis); - } - - private static class LeakyBucketEntity { - @JsonProperty - private int bucketSize; - - @JsonProperty - private double leakRatePerMillis; - - @JsonProperty - private int spaceRemaining; - - @JsonProperty - private long lastUpdateTimeMillis; - - public LeakyBucketEntity() {} - - private LeakyBucketEntity(int bucketSize, double leakRatePerMillis, - int spaceRemaining, long lastUpdateTimeMillis) - { - this.bucketSize = bucketSize; - this.leakRatePerMillis = leakRatePerMillis; - this.spaceRemaining = spaceRemaining; - this.lastUpdateTimeMillis = lastUpdateTimeMillis; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/LockingRateLimiter.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/LockingRateLimiter.java deleted file mode 100644 index f40522127..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/LockingRateLimiter.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import io.lettuce.core.SetArgs; -import java.time.Duration; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.Constants; - -public class LockingRateLimiter extends RateLimiter { - - private final Meter meter; - - public LockingRateLimiter(FaultTolerantRedisCluster cacheCluster, String name, int bucketSize, double leakRatePerMinute) { - super(cacheCluster, name, bucketSize, leakRatePerMinute); - - MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - this.meter = metricRegistry.meter(name(getClass(), name, "locked")); - } - - @Override - public void validate(String key, int amount) throws RateLimitExceededException { - if (!acquireLock(key)) { - meter.mark(); - throw new RateLimitExceededException(Duration.ZERO, true); - } - - try { - super.validate(key, amount); - } finally { - releaseLock(key); - } - } - - @Override - public void validate(String key) throws RateLimitExceededException { - validate(key, 1); - } - - private void releaseLock(String key) { - cacheCluster.useCluster(connection -> connection.sync().del(getLockName(key))); - } - - private boolean acquireLock(String key) { - return cacheCluster.withCluster(connection -> connection.sync().set(getLockName(key), "L", SetArgs.Builder.nx().ex(10))) != null; - } - - private String getLockName(String key) { - return "leaky_lock::" + name + "::" + key; - } - - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java deleted file mode 100644 index 4ec20570d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import static com.codahale.metrics.MetricRegistry.name; - -import io.micrometer.core.instrument.Metrics; -import java.security.SecureRandom; -import java.time.Duration; -import java.util.HexFormat; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb; -import org.whispersystems.textsecuregcm.util.Util; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; - -public class PushChallengeManager { - private final PushNotificationManager pushNotificationManager; - private final PushChallengeDynamoDb pushChallengeDynamoDb; - - private final SecureRandom random = new SecureRandom(); - - private static final int CHALLENGE_TOKEN_LENGTH = 16; - private static final Duration CHALLENGE_TTL = Duration.ofMinutes(5); - - private static final String CHALLENGE_REQUESTED_COUNTER_NAME = name(PushChallengeManager.class, "requested"); - private static final String CHALLENGE_ANSWERED_COUNTER_NAME = name(PushChallengeManager.class, "answered"); - - private static final String PLATFORM_TAG_NAME = "platform"; - private static final String SENT_TAG_NAME = "sent"; - private static final String SUCCESS_TAG_NAME = "success"; - private static final String SOURCE_COUNTRY_TAG_NAME = "sourceCountry"; - - public PushChallengeManager(final PushNotificationManager pushNotificationManager, - final PushChallengeDynamoDb pushChallengeDynamoDb) { - - this.pushNotificationManager = pushNotificationManager; - this.pushChallengeDynamoDb = pushChallengeDynamoDb; - } - - public void sendChallenge(final Account account) throws NotPushRegisteredException { - final Device masterDevice = account.getMasterDevice().orElseThrow(NotPushRegisteredException::new); - - final byte[] token = new byte[CHALLENGE_TOKEN_LENGTH]; - random.nextBytes(token); - - final boolean sent; - final String platform; - - if (pushChallengeDynamoDb.add(account.getUuid(), token, CHALLENGE_TTL)) { - pushNotificationManager.sendRateLimitChallengeNotification(account, HexFormat.of().formatHex(token)); - - sent = true; - - if (StringUtils.isNotBlank(masterDevice.getGcmId())) { - platform = ClientPlatform.ANDROID.name().toLowerCase(); - } else if (StringUtils.isNotBlank(masterDevice.getApnId())) { - platform = ClientPlatform.IOS.name().toLowerCase(); - } else { - // This should never happen; if the account has neither an APN nor FCM token, sending the challenge will result - // in a `NotPushRegisteredException` - platform = "unrecognized"; - } - } else { - sent = false; - platform = "unrecognized"; - } - - Metrics.counter(CHALLENGE_REQUESTED_COUNTER_NAME, - PLATFORM_TAG_NAME, platform, - SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber()), - SENT_TAG_NAME, String.valueOf(sent)).increment(); - } - - public boolean answerChallenge(final Account account, final String challengeTokenHex) { - boolean success = false; - - try { - success = pushChallengeDynamoDb.remove(account.getUuid(), HexFormat.of().parseHex(challengeTokenHex)); - } catch (final IllegalArgumentException ignored) { - } - - final String platform = account.getMasterDevice().map(masterDevice -> { - if (StringUtils.isNotBlank(masterDevice.getGcmId())) { - return ClientPlatform.IOS.name().toLowerCase(); - } else if (StringUtils.isNotBlank(masterDevice.getApnId())) { - return ClientPlatform.ANDROID.name().toLowerCase(); - } else { - return "unknown"; - } - }).orElse("unknown"); - - - Metrics.counter(CHALLENGE_ANSWERED_COUNTER_NAME, - PLATFORM_TAG_NAME, platform, - SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber()), - SUCCESS_TAG_NAME, String.valueOf(success)).increment(); - - return success; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitByIpFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitByIpFilter.java deleted file mode 100644 index 6e6b230f4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitByIpFilter.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import static java.util.Objects.requireNonNull; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import java.io.IOException; -import java.time.Duration; -import java.util.Optional; -import javax.ws.rs.ClientErrorException; -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.container.ContainerRequestFilter; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import org.glassfish.jersey.server.ExtendedUriInfo; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.util.HeaderUtils; - -public class RateLimitByIpFilter implements ContainerRequestFilter { - - private static final Logger logger = LoggerFactory.getLogger(RateLimitByIpFilter.class); - - @VisibleForTesting - static final RateLimitExceededException INVALID_HEADER_EXCEPTION = new RateLimitExceededException(Duration.ofHours(1), - true); - - private static final ExceptionMapper EXCEPTION_MAPPER = new RateLimitExceededExceptionMapper(); - - private final RateLimiters rateLimiters; - - - public RateLimitByIpFilter(final RateLimiters rateLimiters) { - this.rateLimiters = requireNonNull(rateLimiters); - } - - @Override - public void filter(final ContainerRequestContext requestContext) throws IOException { - // requestContext.getUriInfo() should always be an instance of `ExtendedUriInfo` - // in the Jersey client - if (!(requestContext.getUriInfo() instanceof final ExtendedUriInfo uriInfo)) { - return; - } - - final RateLimitedByIp annotation = uriInfo.getMatchedResourceMethod() - .getInvocable() - .getHandlingMethod() - .getAnnotation(RateLimitedByIp.class); - - if (annotation == null) { - return; - } - - final RateLimiters.Handle handle = annotation.value(); - - try { - final String xffHeader = requestContext.getHeaders().getFirst(HttpHeaders.X_FORWARDED_FOR); - final Optional maybeMostRecentProxy = Optional.ofNullable(xffHeader) - .flatMap(HeaderUtils::getMostRecentProxy); - - // checking if we failed to extract the most recent IP from the X-Forwarded-For header - // for any reason - if (maybeMostRecentProxy.isEmpty()) { - // checking if annotation is configured to fail when the most recent IP is not resolved - if (annotation.failOnUnresolvedIp()) { - logger.error("Missing/bad X-Forwarded-For: {}", xffHeader); - throw INVALID_HEADER_EXCEPTION; - } - // otherwise, allow request - return; - } - - final Optional maybeRateLimiter = rateLimiters.byHandle(handle); - if (maybeRateLimiter.isEmpty()) { - logger.warn("RateLimiter not found for {}. Make sure it's initialized in RateLimiters class", handle); - return; - } - - maybeRateLimiter.get().validate(maybeMostRecentProxy.get()); - } catch (RateLimitExceededException e) { - final Response response = EXCEPTION_MAPPER.toResponse(e); - throw new ClientErrorException(response); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java deleted file mode 100644 index eb1d6a941..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.whispersystems.textsecuregcm.limits; - -import static com.codahale.metrics.MetricRegistry.name; - -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener; -import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.util.Util; - -public class RateLimitChallengeManager { - - private final PushChallengeManager pushChallengeManager; - private final CaptchaChecker captchaChecker; - private final DynamicRateLimiters rateLimiters; - - private final List rateLimitChallengeListeners = - Collections.synchronizedList(new ArrayList<>()); - - private static final String RECAPTCHA_ATTEMPT_COUNTER_NAME = name(RateLimitChallengeManager.class, "recaptcha", "attempt"); - private static final String RESET_RATE_LIMIT_EXCEEDED_COUNTER_NAME = name(RateLimitChallengeManager.class, "resetRateLimitExceeded"); - - private static final String SOURCE_COUNTRY_TAG_NAME = "sourceCountry"; - private static final String SUCCESS_TAG_NAME = "success"; - - public RateLimitChallengeManager( - final PushChallengeManager pushChallengeManager, - final CaptchaChecker captchaChecker, - final DynamicRateLimiters rateLimiters) { - - this.pushChallengeManager = pushChallengeManager; - this.captchaChecker = captchaChecker; - this.rateLimiters = rateLimiters; - } - - public void addListener(final RateLimitChallengeListener rateLimitChallengeListener) { - rateLimitChallengeListeners.add(rateLimitChallengeListener); - } - - public void answerPushChallenge(final Account account, final String challenge) throws RateLimitExceededException { - rateLimiters.getPushChallengeAttemptLimiter().validate(account.getUuid()); - - final boolean challengeSuccess = pushChallengeManager.answerChallenge(account, challenge); - - if (challengeSuccess) { - rateLimiters.getPushChallengeSuccessLimiter().validate(account.getUuid()); - resetRateLimits(account); - } - } - - public void answerRecaptchaChallenge(final Account account, final String captcha, final String mostRecentProxyIp, final String userAgent) - throws RateLimitExceededException, IOException { - - rateLimiters.getRecaptchaChallengeAttemptLimiter().validate(account.getUuid()); - - final boolean challengeSuccess = captchaChecker.verify(captcha, mostRecentProxyIp).valid(); - - final Tags tags = Tags.of( - Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())), - Tag.of(SUCCESS_TAG_NAME, String.valueOf(challengeSuccess)), - UserAgentTagUtil.getPlatformTag(userAgent) - ); - - Metrics.counter(RECAPTCHA_ATTEMPT_COUNTER_NAME, tags).increment(); - - if (challengeSuccess) { - rateLimiters.getRecaptchaChallengeSuccessLimiter().validate(account.getUuid()); - resetRateLimits(account); - } - } - - private void resetRateLimits(final Account account) throws RateLimitExceededException { - try { - rateLimiters.getRateLimitResetLimiter().validate(account.getUuid()); - } catch (final RateLimitExceededException e) { - Metrics.counter(RESET_RATE_LIMIT_EXCEEDED_COUNTER_NAME, - SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())).increment(); - - throw e; - } - - rateLimitChallengeListeners.forEach(listener -> listener.handleRateLimitChallengeAnswered(account)); - } - - public void sendPushChallenge(final Account account) throws NotPushRegisteredException { - pushChallengeManager.sendChallenge(account); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManager.java deleted file mode 100644 index 92e561783..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManager.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import com.vdurmont.semver4j.Semver; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -public class RateLimitChallengeOptionManager { - - private final DynamicRateLimiters rateLimiters; - private final DynamicConfigurationManager dynamicConfigurationManager; - - public static final String OPTION_RECAPTCHA = "recaptcha"; - public static final String OPTION_PUSH_CHALLENGE = "pushChallenge"; - - public RateLimitChallengeOptionManager(final DynamicRateLimiters rateLimiters, - final DynamicConfigurationManager dynamicConfigurationManager) { - - this.rateLimiters = rateLimiters; - this.dynamicConfigurationManager = dynamicConfigurationManager; - } - - public boolean isClientBelowMinimumVersion(final String userAgent) { - try { - final UserAgent client = UserAgentUtil.parseUserAgentString(userAgent); - final Optional minimumClientVersion = dynamicConfigurationManager.getConfiguration() - .getRateLimitChallengeConfiguration() - .getMinimumSupportedVersion(client.getPlatform()); - - return minimumClientVersion.map(version -> version.isGreaterThan(client.getVersion())) - .orElse(true); - } catch (final UnrecognizedUserAgentException ignored) { - return false; - } - } - - public List getChallengeOptions(final Account account) { - final List options = new ArrayList<>(2); - - if (rateLimiters.getRecaptchaChallengeAttemptLimiter().hasAvailablePermits(account.getUuid(), 1) && - rateLimiters.getRecaptchaChallengeSuccessLimiter().hasAvailablePermits(account.getUuid(), 1)) { - - options.add(OPTION_RECAPTCHA); - } - - if (rateLimiters.getPushChallengeAttemptLimiter().hasAvailablePermits(account.getUuid(), 1) && - rateLimiters.getPushChallengeSuccessLimiter().hasAvailablePermits(account.getUuid(), 1)) { - - options.add(OPTION_PUSH_CHALLENGE); - } - - return options; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIp.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIp.java deleted file mode 100644 index 29da64979..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIp.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface RateLimitedByIp { - - RateLimiters.Handle value(); - - boolean failOnUnresolvedIp() default true; -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java deleted file mode 100644 index 17486a63d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.limits; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.time.Duration; -import java.util.UUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -public class RateLimiter { - - private final Logger logger = LoggerFactory.getLogger(RateLimiter.class); - private final ObjectMapper mapper = SystemMapper.getMapper(); - - private final Meter meter; - private final Timer validateTimer; - protected final FaultTolerantRedisCluster cacheCluster; - protected final String name; - private final int bucketSize; - private final double leakRatePerMinute; - private final double leakRatePerMillis; - - public RateLimiter(FaultTolerantRedisCluster cacheCluster, String name, int bucketSize, double leakRatePerMinute) - { - MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - - this.meter = metricRegistry.meter(name(getClass(), name, "exceeded")); - this.validateTimer = metricRegistry.timer(name(getClass(), name, "validate")); - this.cacheCluster = cacheCluster; - this.name = name; - this.bucketSize = bucketSize; - this.leakRatePerMinute = leakRatePerMinute; - this.leakRatePerMillis = leakRatePerMinute / (60.0 * 1000.0); - } - - public void validate(String key, int amount) throws RateLimitExceededException { - try (final Timer.Context ignored = validateTimer.time()) { - LeakyBucket bucket = getBucket(key); - - if (bucket.add(amount)) { - setBucket(key, bucket); - } else { - meter.mark(); - throw new RateLimitExceededException(bucket.getTimeUntilSpaceAvailable(amount), true); - } - } - } - - public void validate(final UUID accountUuid) throws RateLimitExceededException { - validate(accountUuid.toString()); - } - - public void validate(final UUID sourceAccountUuid, final UUID destinationAccountUuid) - throws RateLimitExceededException { - - validate(sourceAccountUuid.toString() + "__" + destinationAccountUuid.toString()); - } - - public void validate(String key) throws RateLimitExceededException { - validate(key, 1); - } - - public boolean hasAvailablePermits(final UUID accountUuid, final int permits) { - return hasAvailablePermits(accountUuid.toString(), permits); - } - - public boolean hasAvailablePermits(final String key, final int permits) { - return getBucket(key).getTimeUntilSpaceAvailable(permits).equals(Duration.ZERO); - } - - public void clear(final UUID accountUuid) { - clear(accountUuid.toString()); - } - - public void clear(String key) { - cacheCluster.useCluster(connection -> connection.sync().del(getBucketName(key))); - } - - public int getBucketSize() { - return bucketSize; - } - - public double getLeakRatePerMinute() { - return leakRatePerMinute; - } - - private void setBucket(String key, LeakyBucket bucket) { - - try { - final String serialized = bucket.serialize(mapper); - - cacheCluster.useCluster(connection -> connection.sync().setex(getBucketName(key), (int) Math.ceil((bucketSize / leakRatePerMillis) / 1000), serialized)); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(e); - } - } - - private LeakyBucket getBucket(String key) { - try { - final String serialized = cacheCluster.withCluster(connection -> connection.sync().get(getBucketName(key))); - - if (serialized != null) { - return LeakyBucket.fromSerialized(mapper, serialized); - } - } catch (IOException e) { - logger.warn("Deserialization error", e); - } - - return new LeakyBucket(bucketSize, leakRatePerMillis); - } - - private String getBucketName(String key) { - return "leaky_bucket::" + name + "::" + key; - } - - public boolean hasConfiguration(final RateLimitConfiguration configuration) { - return bucketSize == configuration.getBucketSize() && leakRatePerMinute == configuration.getLeakRatePerMinute(); - } - - /** - * If the wrapped {@code validate()} call throws a {@link RateLimitExceededException}, it will adapt it to ensure that - * {@link RateLimitExceededException#isLegacy()} returns {@code true} - */ - public static void adaptLegacyException(final RateLimitValidator validator) throws RateLimitExceededException { - try { - validator.validate(); - } catch (final RateLimitExceededException e) { - throw new RateLimitExceededException(e.getRetryDuration().orElse(null), false); - } - } - - @FunctionalInterface - public interface RateLimitValidator { - - void validate() throws RateLimitExceededException; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java deleted file mode 100644 index 15967b426..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.limits; - - -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.apache.commons.lang3.tuple.Pair; -import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; - -public class RateLimiters { - - public enum Handle { - USERNAME_LOOKUP("usernameLookup"), - CHECK_ACCOUNT_EXISTENCE("checkAccountExistence"), - BACKUP_AUTH_CHECK; - - private final String id; - - - Handle(final String id) { - this.id = id; - } - - Handle() { - this.id = name(); - } - - public String id() { - return id; - } - } - - private final RateLimiter smsDestinationLimiter; - private final RateLimiter voiceDestinationLimiter; - private final RateLimiter voiceDestinationDailyLimiter; - private final RateLimiter smsVoiceIpLimiter; - private final RateLimiter smsVoicePrefixLimiter; - private final RateLimiter verifyLimiter; - private final RateLimiter pinLimiter; - private final RateLimiter registrationLimiter; - private final RateLimiter attachmentLimiter; - private final RateLimiter preKeysLimiter; - private final RateLimiter messagesLimiter; - private final RateLimiter allocateDeviceLimiter; - private final RateLimiter verifyDeviceLimiter; - private final RateLimiter turnLimiter; - private final RateLimiter profileLimiter; - private final RateLimiter stickerPackLimiter; - private final RateLimiter artPackLimiter; - private final RateLimiter usernameSetLimiter; - private final RateLimiter usernameReserveLimiter; - - private final Map rateLimiterByHandle; - - public RateLimiters(final RateLimitsConfiguration config, final FaultTolerantRedisCluster cacheCluster) { - this.smsDestinationLimiter = fromConfig("smsDestination", config.getSmsDestination(), cacheCluster); - this.voiceDestinationLimiter = fromConfig("voxDestination", config.getVoiceDestination(), cacheCluster); - this.voiceDestinationDailyLimiter = fromConfig("voxDestinationDaily", config.getVoiceDestinationDaily(), cacheCluster); - this.smsVoiceIpLimiter = fromConfig("smsVoiceIp", config.getSmsVoiceIp(), cacheCluster); - this.smsVoicePrefixLimiter = fromConfig("smsVoicePrefix", config.getSmsVoicePrefix(), cacheCluster); - this.verifyLimiter = fromConfig("verify", config.getVerifyNumber(), cacheCluster); - this.pinLimiter = fromConfig("pin", config.getVerifyPin(), cacheCluster); - this.registrationLimiter = fromConfig("registration", config.getRegistration(), cacheCluster); - this.attachmentLimiter = fromConfig("attachmentCreate", config.getAttachments(), cacheCluster); - this.preKeysLimiter = fromConfig("prekeys", config.getPreKeys(), cacheCluster); - this.messagesLimiter = fromConfig("messages", config.getMessages(), cacheCluster); - this.allocateDeviceLimiter = fromConfig("allocateDevice", config.getAllocateDevice(), cacheCluster); - this.verifyDeviceLimiter = fromConfig("verifyDevice", config.getVerifyDevice(), cacheCluster); - this.turnLimiter = fromConfig("turnAllocate", config.getTurnAllocations(), cacheCluster); - this.profileLimiter = fromConfig("profile", config.getProfile(), cacheCluster); - this.stickerPackLimiter = fromConfig("stickerPack", config.getStickerPack(), cacheCluster); - this.artPackLimiter = fromConfig("artPack", config.getArtPack(), cacheCluster); - this.usernameSetLimiter = fromConfig("usernameSet", config.getUsernameSet(), cacheCluster); - this.usernameReserveLimiter = fromConfig("usernameReserve", config.getUsernameReserve(), cacheCluster); - - this.rateLimiterByHandle = Stream.of( - fromConfig(Handle.BACKUP_AUTH_CHECK.id(), config.getBackupAuthCheck(), cacheCluster), - fromConfig(Handle.CHECK_ACCOUNT_EXISTENCE.id(), config.getCheckAccountExistence(), cacheCluster), - fromConfig(Handle.USERNAME_LOOKUP.id(), config.getUsernameLookup(), cacheCluster) - ).map(rl -> Pair.of(rl.name, rl)).collect(Collectors.toMap(Pair::getKey, Pair::getValue)); - } - - public Optional byHandle(final Handle handle) { - return Optional.ofNullable(rateLimiterByHandle.get(handle.id())); - } - - public RateLimiter getAllocateDeviceLimiter() { - return allocateDeviceLimiter; - } - - public RateLimiter getVerifyDeviceLimiter() { - return verifyDeviceLimiter; - } - - public RateLimiter getMessagesLimiter() { - return messagesLimiter; - } - - public RateLimiter getPreKeysLimiter() { - return preKeysLimiter; - } - - public RateLimiter getAttachmentLimiter() { - return this.attachmentLimiter; - } - - public RateLimiter getSmsDestinationLimiter() { - return smsDestinationLimiter; - } - - public RateLimiter getSmsVoiceIpLimiter() { - return smsVoiceIpLimiter; - } - - public RateLimiter getSmsVoicePrefixLimiter() { - return smsVoicePrefixLimiter; - } - - public RateLimiter getVoiceDestinationLimiter() { - return voiceDestinationLimiter; - } - - public RateLimiter getVoiceDestinationDailyLimiter() { - return voiceDestinationDailyLimiter; - } - - public RateLimiter getVerifyLimiter() { - return verifyLimiter; - } - - public RateLimiter getPinLimiter() { - return pinLimiter; - } - - public RateLimiter getRegistrationLimiter() { - return registrationLimiter; - } - - public RateLimiter getTurnLimiter() { - return turnLimiter; - } - - public RateLimiter getProfileLimiter() { - return profileLimiter; - } - - public RateLimiter getStickerPackLimiter() { - return stickerPackLimiter; - } - - public RateLimiter getArtPackLimiter() { - return artPackLimiter; - } - - public RateLimiter getUsernameLookupLimiter() { - return byHandle(Handle.USERNAME_LOOKUP).orElseThrow(); - } - - public RateLimiter getUsernameSetLimiter() { - return usernameSetLimiter; - } - - public RateLimiter getUsernameReserveLimiter() { - return usernameReserveLimiter; - } - - public RateLimiter getCheckAccountExistenceLimiter() { - return byHandle(Handle.CHECK_ACCOUNT_EXISTENCE).orElseThrow(); - } - - private static RateLimiter fromConfig( - final String name, - final RateLimitsConfiguration.RateLimitConfiguration cfg, - final FaultTolerantRedisCluster cacheCluster) { - return new RateLimiter(cacheCluster, name, cfg.getBucketSize(), cfg.getLeakRatePerMinute()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/CompletionExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/CompletionExceptionMapper.java deleted file mode 100644 index 9cb6dbc44..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/CompletionExceptionMapper.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.mappers; - -import java.util.Optional; -import java.util.concurrent.CompletionException; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; -import javax.ws.rs.ext.Providers; - -@Provider -public class CompletionExceptionMapper implements ExceptionMapper { - - @Context - private Providers providers; - - @Override - public Response toResponse(final CompletionException exception) { - final Throwable cause = exception.getCause(); - - if (cause != null) { - - final Class type = cause.getClass(); - final ExceptionMapper exceptionMapper = providers.getExceptionMapper(type); - - // some exception mappers, like LoggingExceptionMapper, have side effects (e.g., logging) - // so we always build their response… - final Response exceptionMapperResponse = exceptionMapper.toResponse(cause); - - final Optional webApplicationExceptionResponse; - if (cause instanceof WebApplicationException webApplicationException) { - webApplicationExceptionResponse = Optional.of(webApplicationException.getResponse()); - } else { - webApplicationExceptionResponse = Optional.empty(); - } - - // …but if the exception was a WebApplicationException, and provides an entity, we want to keep it - return webApplicationExceptionResponse - .filter(Response::hasEntity) - .orElse(exceptionMapperResponse); - } - - return Response.serverError().build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/DeviceLimitExceededExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/DeviceLimitExceededExceptionMapper.java deleted file mode 100644 index 3252c5665..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/DeviceLimitExceededExceptionMapper.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.mappers; - - -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.whispersystems.textsecuregcm.controllers.DeviceLimitExceededException; - -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -@Provider -public class DeviceLimitExceededExceptionMapper implements ExceptionMapper { - @Override - public Response toResponse(DeviceLimitExceededException exception) { - return Response.status(411) - .entity(new DeviceLimitExceededDetails(exception.getCurrentDevices(), - exception.getMaxDevices())) - .build(); - } - - private static class DeviceLimitExceededDetails { - @JsonProperty - private int current; - @JsonProperty - private int max; - - public DeviceLimitExceededDetails(int current, int max) { - this.current = current; - this.max = max; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/IOExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/IOExceptionMapper.java deleted file mode 100644 index c57d16755..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/IOExceptionMapper.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.mappers; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; -import java.io.IOException; - -@Provider -public class IOExceptionMapper implements ExceptionMapper { - - private final Logger logger = LoggerFactory.getLogger(IOExceptionMapper.class); - - @Override - public Response toResponse(IOException e) { - if (!(e.getCause() instanceof java.util.concurrent.TimeoutException)) { - logger.warn("IOExceptionMapper", e); - } - return Response.status(503).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/ImpossiblePhoneNumberExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/ImpossiblePhoneNumberExceptionMapper.java deleted file mode 100644 index edbd670fa..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/ImpossiblePhoneNumberExceptionMapper.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.mappers; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -public class ImpossiblePhoneNumberExceptionMapper implements ExceptionMapper { - - private static final Counter IMPOSSIBLE_NUMBER_COUNTER = - Metrics.counter(name(ImpossiblePhoneNumberExceptionMapper.class, "impossibleNumbers")); - - @Override - public Response toResponse(final ImpossiblePhoneNumberException exception) { - IMPOSSIBLE_NUMBER_COUNTER.increment(); - - return Response.status(Response.Status.BAD_REQUEST).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/InvalidWebsocketAddressExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/InvalidWebsocketAddressExceptionMapper.java deleted file mode 100644 index 5ad66661a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/InvalidWebsocketAddressExceptionMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.mappers; - -import org.whispersystems.textsecuregcm.websocket.InvalidWebsocketAddressException; - -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; - -@Provider -public class InvalidWebsocketAddressExceptionMapper implements ExceptionMapper { - @Override - public Response toResponse(InvalidWebsocketAddressException exception) { - return Response.status(Response.Status.BAD_REQUEST).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/JsonMappingExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/JsonMappingExceptionMapper.java deleted file mode 100644 index 515a27296..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/JsonMappingExceptionMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.whispersystems.textsecuregcm.mappers; - -import com.fasterxml.jackson.databind.JsonMappingException; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; - -public class JsonMappingExceptionMapper implements ExceptionMapper { - @Override - public Response toResponse(final JsonMappingException exception) { - return Response.status(422).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberExceptionMapper.java deleted file mode 100644 index 9a4b26b9f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberExceptionMapper.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.mappers; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import io.micrometer.core.instrument.Metrics; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import javax.ws.rs.ext.ExceptionMapper; -import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException; - -public class NonNormalizedPhoneNumberExceptionMapper implements ExceptionMapper { - - private static final String NON_NORMALIZED_NUMBER_COUNTER_NAME = - name(NonNormalizedPhoneNumberExceptionMapper.class, "nonNormalizedNumbers"); - - @Override - public Response toResponse(final NonNormalizedPhoneNumberException exception) { - String countryCode; - - try { - countryCode = - String.valueOf(PhoneNumberUtil.getInstance().parse(exception.getOriginalNumber(), null).getCountryCode()); - } catch (final NumberParseException ignored) { - countryCode = "unknown"; - } - - Metrics.counter(NON_NORMALIZED_NUMBER_COUNTER_NAME, "countryCode", countryCode).increment(); - - return Response.status(Status.BAD_REQUEST) - .entity(new NonNormalizedPhoneNumberResponse(exception.getOriginalNumber(), exception.getNormalizedNumber())) - .build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberResponse.java deleted file mode 100644 index 155ae73bf..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.mappers; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -public class NonNormalizedPhoneNumberResponse { - - private final String originalNumber; - private final String normalizedNumber; - - @JsonCreator - NonNormalizedPhoneNumberResponse(@JsonProperty("originalNumber") final String originalNumber, - @JsonProperty("normalizedNumber") final String normalizedNumber) { - - this.originalNumber = originalNumber; - this.normalizedNumber = normalizedNumber; - } - - public String getOriginalNumber() { - return originalNumber; - } - - public String getNormalizedNumber() { - return normalizedNumber; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/RateLimitExceededExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/RateLimitExceededExceptionMapper.java deleted file mode 100644 index 3202933b8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/RateLimitExceededExceptionMapper.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.mappers; - -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; - -@Provider -public class RateLimitExceededExceptionMapper implements ExceptionMapper { - - private static final Logger logger = LoggerFactory.getLogger(RateLimitExceededExceptionMapper.class); - - private static final int LEGACY_STATUS_CODE = 413; - private static final int STATUS_CODE = 429; - - /** - * Convert a RateLimitExceededException to a {@value STATUS_CODE} (or legacy {@value LEGACY_STATUS_CODE}) response - * with a Retry-After header. - * - * @param e A RateLimitExceededException potentially containing a recommended retry duration - * @return the response - */ - @Override - public Response toResponse(RateLimitExceededException e) { - final int statusCode = e.isLegacy() ? LEGACY_STATUS_CODE : STATUS_CODE; - return e.getRetryDuration() - .filter(d -> { - if (d.isNegative()) { - logger.warn("Encountered a negative retry duration: {}, will not include a Retry-After header in response", - d); - } - // only include non-negative durations in retry headers - return !d.isNegative(); - }) - .map(d -> Response.status(statusCode).header("Retry-After", d.toSeconds())) - .orElseGet(() -> Response.status(statusCode)).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/ServerRejectedExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/mappers/ServerRejectedExceptionMapper.java deleted file mode 100644 index 37f8787e5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/mappers/ServerRejectedExceptionMapper.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.mappers; - -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import org.whispersystems.textsecuregcm.controllers.ServerRejectedException; - -public class ServerRejectedExceptionMapper implements ExceptionMapper { - - @Override - public Response toResponse(final ServerRejectedException exception) { - return Response.status(508).build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/ApplicationShutdownMonitor.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/ApplicationShutdownMonitor.java deleted file mode 100644 index b9f13df8a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/ApplicationShutdownMonitor.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - - -import static com.codahale.metrics.MetricRegistry.name; - -import io.dropwizard.lifecycle.Managed; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.MeterRegistry; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * A managed monitor that reports whether the application is shutting down as a metric. That metric can then be used in - * conjunction with other indicators to conditionally fire or suppress alerts. - */ -public class ApplicationShutdownMonitor implements Managed { - - private final AtomicBoolean shuttingDown = new AtomicBoolean(false); - - public ApplicationShutdownMonitor(final MeterRegistry meterRegistry) { - // without a strong reference to the gauge’s value supplier, shutdown garbage collection - // might prevent the final value from being reported - Gauge.builder(name(getClass().getSimpleName(), "shuttingDown"), () -> shuttingDown.get() ? 1 : 0) - .strongReference(true) - .register(meterRegistry); - } - - @Override - public void start() throws Exception { - shuttingDown.set(false); - } - - @Override - public void stop() throws Exception { - shuttingDown.set(true); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/BufferPoolGauges.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/BufferPoolGauges.java deleted file mode 100644 index 4f6ea36ff..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/BufferPoolGauges.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; - -import java.lang.management.BufferPoolMXBean; -import java.lang.management.ManagementFactory; -import java.util.List; - -import static com.codahale.metrics.MetricRegistry.name; - -public class BufferPoolGauges { - - private BufferPoolGauges() {} - - public static void registerMetrics() { - for (final BufferPoolMXBean bufferPoolMXBean : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) { - final List tags = List.of(Tag.of("name", bufferPoolMXBean.getName())); - - Metrics.gauge(name(BufferPoolGauges.class, "count"), tags, bufferPoolMXBean, BufferPoolMXBean::getCount); - Metrics.gauge(name(BufferPoolGauges.class, "memory_used"), tags, bufferPoolMXBean, BufferPoolMXBean::getMemoryUsed); - Metrics.gauge(name(BufferPoolGauges.class, "total_capacity"), tags, bufferPoolMXBean, BufferPoolMXBean::getTotalCapacity); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/CpuUsageGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/CpuUsageGauge.java deleted file mode 100644 index df40e80ce..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/CpuUsageGauge.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import com.codahale.metrics.CachedGauge; -import com.sun.management.OperatingSystemMXBean; - -import java.lang.management.ManagementFactory; -import java.util.concurrent.TimeUnit; - -public class CpuUsageGauge extends CachedGauge { - - private final OperatingSystemMXBean operatingSystemMXBean; - - public CpuUsageGauge(final long timeout, final TimeUnit timeoutUnit) { - super(timeout, timeoutUnit); - - this.operatingSystemMXBean = (com.sun.management.OperatingSystemMXBean) - ManagementFactory.getOperatingSystemMXBean(); - } - - @Override - protected Integer loadValue() { - return (int) Math.ceil(operatingSystemMXBean.getCpuLoad() * 100); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/FileDescriptorGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/FileDescriptorGauge.java deleted file mode 100644 index 472e2f560..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/FileDescriptorGauge.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - - -import com.codahale.metrics.Gauge; - -import java.io.File; - -public class FileDescriptorGauge implements Gauge { - @Override - public Integer getValue() { - File file = new File("/proc/self/fd"); - - if (file.isDirectory() && file.exists()) { - return file.list().length; - } - - return 0; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/FreeMemoryGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/FreeMemoryGauge.java deleted file mode 100644 index a04330b36..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/FreeMemoryGauge.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import com.codahale.metrics.Gauge; -import com.sun.management.OperatingSystemMXBean; -import java.lang.management.ManagementFactory; - -public class FreeMemoryGauge implements Gauge { - - private final OperatingSystemMXBean operatingSystemMXBean; - - public FreeMemoryGauge() { - this.operatingSystemMXBean = (com.sun.management.OperatingSystemMXBean) - ManagementFactory.getOperatingSystemMXBean(); - } - - @Override - public Long getValue() { - return operatingSystemMXBean.getFreeMemorySize(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/GarbageCollectionGauges.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/GarbageCollectionGauges.java deleted file mode 100644 index 5eca2d5fd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/GarbageCollectionGauges.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; - -import java.lang.management.GarbageCollectorMXBean; -import java.lang.management.ManagementFactory; -import java.util.List; - -import static com.codahale.metrics.MetricRegistry.name; - -public class GarbageCollectionGauges { - - private GarbageCollectionGauges() {} - - public static void registerMetrics() { - for (final GarbageCollectorMXBean garbageCollectorMXBean : ManagementFactory.getGarbageCollectorMXBeans()) { - final List tags = List.of(Tag.of("name", garbageCollectorMXBean.getName())); - - Metrics.gauge(name(GarbageCollectionGauges.class, "collection_count"), tags, garbageCollectorMXBean, GarbageCollectorMXBean::getCollectionCount); - Metrics.gauge(name(GarbageCollectionGauges.class, "collection_time"), tags, garbageCollectorMXBean, GarbageCollectorMXBean::getCollectionTime); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/LettuceMetricsMeterFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/LettuceMetricsMeterFilter.java deleted file mode 100644 index b5a53d3e6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/LettuceMetricsMeterFilter.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import io.micrometer.core.instrument.Meter; -import io.micrometer.core.instrument.config.MeterFilter; - -public class LettuceMetricsMeterFilter implements MeterFilter { - - private static final String METRIC_NAME_PREFIX = "lettuce.command"; - private static final String REMOTE_TAG = "remote"; - - // the `remote` tag is very high-cardinality, so we ignore it. - // In the future, it would be nice to map a remote (address:port) to a logical cluster name - private static final MeterFilter IGNORE_TAGS_FILTER = MeterFilter.ignoreTags(REMOTE_TAG); - - @Override - public Meter.Id map(final Meter.Id id) { - - if (id.getName().startsWith(METRIC_NAME_PREFIX)) { - return IGNORE_TAGS_FILTER.map(id); - } - - return id; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/LogstashTcpSocketAppenderFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/LogstashTcpSocketAppenderFactory.java deleted file mode 100644 index e199bab73..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/LogstashTcpSocketAppenderFactory.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.PatternLayout; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.Appender; -import ch.qos.logback.core.encoder.LayoutWrappingEncoder; -import ch.qos.logback.core.net.ssl.SSLConfiguration; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonTypeName; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.TextNode; -import io.dropwizard.logging.AbstractAppenderFactory; -import io.dropwizard.logging.async.AsyncAppenderFactory; -import io.dropwizard.logging.filter.LevelFilterFactory; -import io.dropwizard.logging.layout.LayoutFactory; -import java.time.Duration; -import javax.validation.constraints.NotEmpty; -import net.logstash.logback.appender.LogstashTcpSocketAppender; -import net.logstash.logback.encoder.LogstashEncoder; -import org.whispersystems.textsecuregcm.WhisperServerVersion; -import org.whispersystems.textsecuregcm.util.HostnameUtil; - -@JsonTypeName("logstashtcpsocket") -public class LogstashTcpSocketAppenderFactory extends AbstractAppenderFactory { - - private String destination; - private Duration keepAlive = Duration.ofSeconds(20); - private String apiKey; - private String environment; - - @JsonProperty - @NotEmpty - public String getDestination() { - return destination; - } - - @JsonProperty - public Duration getKeepAlive() { - return keepAlive; - } - - @JsonProperty - @NotEmpty - public String getApiKey() { - return apiKey; - } - - @JsonProperty - @NotEmpty - public String getEnvironment() { - return environment; - } - - @Override - public Appender build( - final LoggerContext context, - final String applicationName, - final LayoutFactory layoutFactory, - final LevelFilterFactory levelFilterFactory, - final AsyncAppenderFactory asyncAppenderFactory) { - - final SSLConfiguration sslConfiguration = new SSLConfiguration(); - final LogstashTcpSocketAppender appender = new LogstashTcpSocketAppender(); - appender.setName("logstashtcpsocket-appender"); - appender.setContext(context); - appender.setSsl(sslConfiguration); - appender.addDestination(destination); - appender.setKeepAliveDuration(new ch.qos.logback.core.util.Duration(keepAlive.toMillis())); - - final LogstashEncoder encoder = new LogstashEncoder(); - final ObjectNode customFieldsNode = new ObjectNode(JsonNodeFactory.instance); - customFieldsNode.set("host", TextNode.valueOf(HostnameUtil.getLocalHostname())); - customFieldsNode.set("service", TextNode.valueOf("chat")); - customFieldsNode.set("ddsource", TextNode.valueOf("logstash")); - customFieldsNode.set("ddtags", TextNode.valueOf("env:" + environment + ",version:" + WhisperServerVersion.getServerVersion())); - - encoder.setCustomFields(customFieldsNode.toString()); - final LayoutWrappingEncoder prefix = new LayoutWrappingEncoder<>(); - final PatternLayout layout = new PatternLayout(); - layout.setPattern(String.format("%s ", apiKey)); - prefix.setLayout(layout); - encoder.setPrefix(prefix); - appender.setEncoder(encoder); - - appender.addFilter(levelFilterFactory.build(threshold)); - getFilterFactories().forEach(f -> appender.addFilter(f.build())); - appender.start(); - - return wrapAsync(appender, asyncAppenderFactory); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MaxFileDescriptorGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MaxFileDescriptorGauge.java deleted file mode 100644 index 397500859..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MaxFileDescriptorGauge.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import com.codahale.metrics.Gauge; -import com.sun.management.UnixOperatingSystemMXBean; - -import java.lang.management.ManagementFactory; - -/** - * A gauge that reports the maximum number of file descriptors allowed by the operating system. - */ -public class MaxFileDescriptorGauge implements Gauge { - - private final UnixOperatingSystemMXBean unixOperatingSystemMXBean; - - public MaxFileDescriptorGauge() { - this.unixOperatingSystemMXBean = (UnixOperatingSystemMXBean)ManagementFactory.getOperatingSystemMXBean(); - } - - @Override - public Long getValue() { - return unixOperatingSystemMXBean.getMaxFileDescriptorCount(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MessageMetrics.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MessageMetrics.java deleted file mode 100644 index 87cc92d7d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MessageMetrics.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import io.micrometer.core.instrument.Metrics; -import java.util.UUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity; -import org.whispersystems.textsecuregcm.storage.Account; - -public final class MessageMetrics { - - private static final Logger logger = LoggerFactory.getLogger(MessageMetrics.class); - - private static final String MISMATCHED_ACCOUNT_ENVELOPE_UUID_COUNTER_NAME = name(MessageMetrics.class, - "mismatchedAccountEnvelopeUuid"); - - public static void measureAccountOutgoingMessageUuidMismatches(final Account account, - final OutgoingMessageEntity outgoingMessage) { - measureAccountDestinationUuidMismatches(account, outgoingMessage.destinationUuid()); - } - - public static void measureAccountEnvelopeUuidMismatches(final Account account, - final MessageProtos.Envelope envelope) { - if (envelope.hasDestinationUuid()) { - try { - final UUID destinationUuid = UUID.fromString(envelope.getDestinationUuid()); - measureAccountDestinationUuidMismatches(account, destinationUuid); - } catch (final IllegalArgumentException ignored) { - logger.warn("Envelope had invalid destination UUID: {}", envelope.getDestinationUuid()); - } - } - } - - private static void measureAccountDestinationUuidMismatches(final Account account, final UUID destinationUuid) { - if (!destinationUuid.equals(account.getUuid()) && !destinationUuid.equals(account.getPhoneNumberIdentifier())) { - // In all cases, this represents a mismatch between the account’s current PNI and its PNI when the message was - // sent. This is an expected case, but if this metric changes significantly, it could indicate an issue to - // investigate. - Metrics.counter(MISMATCHED_ACCOUNT_ENVELOPE_UUID_COUNTER_NAME).increment(); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsApplicationEventListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsApplicationEventListener.java deleted file mode 100644 index a095329cd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsApplicationEventListener.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import org.glassfish.jersey.server.monitoring.ApplicationEvent; -import org.glassfish.jersey.server.monitoring.ApplicationEventListener; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.glassfish.jersey.server.monitoring.RequestEventListener; - -/** - * Delegates request events to a listener that captures and reports request-level metrics. - */ -public class MetricsApplicationEventListener implements ApplicationEventListener { - - private final MetricsRequestEventListener metricsRequestEventListener; - - public MetricsApplicationEventListener(final TrafficSource trafficSource) { - this.metricsRequestEventListener = new MetricsRequestEventListener(trafficSource); - } - - @Override - public void onEvent(final ApplicationEvent event) { - } - - @Override - public RequestEventListener onRequest(final RequestEvent requestEvent) { - return metricsRequestEventListener; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java deleted file mode 100644 index 3da6229fc..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import com.codahale.metrics.MetricRegistry; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import com.vdurmont.semver4j.Semver; -import com.vdurmont.semver4j.SemverException; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.glassfish.jersey.server.monitoring.RequestEventListener; -import org.whispersystems.textsecuregcm.util.logging.UriInfoUtil; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; - -/** - * Gathers and reports request-level metrics. - */ -public class MetricsRequestEventListener implements RequestEventListener { - - public static final String REQUEST_COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "request"); - public static final String ANDROID_REQUEST_COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "androidRequest"); - public static final String DESKTOP_REQUEST_COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "desktopRequest"); - public static final String IOS_REQUEST_COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "iosRequest"); - - static final String PATH_TAG = "path"; - static final String STATUS_CODE_TAG = "status"; - static final String TRAFFIC_SOURCE_TAG = "trafficSource"; - static final String OS_TAG = "os"; - static final String SDK_TAG = "sdkVersion"; - - private static final Set ACCEPTABLE_DESKTOP_OS_STRINGS = Set.of("linux", "macos", "windows"); - - private static final String ANDROID_SDK_PREFIX = "Android/"; - private static final int MIN_ANDROID_SDK_VERSION = 19; - private static final int MAX_ANDROID_SDK_VERSION = 50; - - private static final String IOS_VERSION_PREFIX = "iOS/"; - private static final Pattern LEGACY_IOS_PATTERN = Pattern.compile("^\\(.*iOS ([0-9\\.]+).*\\)$"); - private static final Semver MIN_IOS_VERSION = new Semver("8.0", Semver.SemverType.LOOSE); - private static final Semver MAX_IOS_VERSION = new Semver("20.0", Semver.SemverType.LOOSE); - - private final TrafficSource trafficSource; - private final MeterRegistry meterRegistry; - - public MetricsRequestEventListener(final TrafficSource trafficSource) { - this(trafficSource, Metrics.globalRegistry); - } - - @VisibleForTesting - MetricsRequestEventListener(final TrafficSource trafficSource, final MeterRegistry meterRegistry) { - this.trafficSource = trafficSource; - this.meterRegistry = meterRegistry; - } - - @Override - public void onEvent(final RequestEvent event) { - if (event.getType() == RequestEvent.Type.FINISHED) { - if (!event.getUriInfo().getMatchedTemplates().isEmpty()) { - final List tags = new ArrayList<>(5); - tags.add(Tag.of(PATH_TAG, UriInfoUtil.getPathTemplate(event.getUriInfo()))); - tags.add(Tag.of(STATUS_CODE_TAG, String.valueOf(event.getContainerResponse().getStatus()))); - tags.add(Tag.of(TRAFFIC_SOURCE_TAG, trafficSource.name().toLowerCase())); - - final List userAgentValues = event.getContainerRequest().getRequestHeader(HttpHeaders.USER_AGENT); - // tags.addAll(UserAgentTagUtil.getUserAgentTags(userAgentValues != null ? userAgentValues.stream().findFirst().orElse(null) : null)); - tags.add(UserAgentTagUtil.getPlatformTag(userAgentValues != null ? userAgentValues.stream().findFirst().orElse(null) : null)); - - meterRegistry.counter(REQUEST_COUNTER_NAME, tags).increment(); - - try { - final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentValues != null ? userAgentValues.stream().findFirst().orElse(null) : null); - - recordDesktopOperatingSystem(userAgent); - recordAndroidSdkVersion(userAgent); - recordIosVersion(userAgent); - } catch (final UnrecognizedUserAgentException ignored) { - } - } - } - } - - @VisibleForTesting - void recordDesktopOperatingSystem(final UserAgent userAgent) { - if (userAgent.getPlatform() == ClientPlatform.DESKTOP) { - if (userAgent.getAdditionalSpecifiers().map(String::toLowerCase).map(ACCEPTABLE_DESKTOP_OS_STRINGS::contains).orElse(false)) { - meterRegistry.counter(DESKTOP_REQUEST_COUNTER_NAME, OS_TAG, userAgent.getAdditionalSpecifiers().get().toLowerCase()).increment(); - } - } - } - - @VisibleForTesting - void recordAndroidSdkVersion(final UserAgent userAgent) { - if (userAgent.getPlatform() == ClientPlatform.ANDROID) { - userAgent.getAdditionalSpecifiers().ifPresent(additionalSpecifiers -> { - if (additionalSpecifiers.startsWith(ANDROID_SDK_PREFIX)) { - try { - final int sdkVersion = Integer.parseInt(additionalSpecifiers, ANDROID_SDK_PREFIX.length(), additionalSpecifiers.length(), 10); - - if (sdkVersion >= MIN_ANDROID_SDK_VERSION && sdkVersion <= MAX_ANDROID_SDK_VERSION) { - meterRegistry.counter(ANDROID_REQUEST_COUNTER_NAME, SDK_TAG, String.valueOf(sdkVersion)).increment(); - } - } catch (final NumberFormatException ignored) { - } - } - }); - } - } - - @VisibleForTesting - void recordIosVersion(final UserAgent userAgent) { - if (userAgent.getPlatform() == ClientPlatform.IOS) { - userAgent.getAdditionalSpecifiers().ifPresent(additionalSpecifiers -> { - Semver iosVersion = null; - - if (additionalSpecifiers.startsWith(IOS_VERSION_PREFIX)) { - try { - iosVersion = new Semver(additionalSpecifiers.substring(IOS_VERSION_PREFIX.length()), Semver.SemverType.LOOSE); - } catch (final SemverException ignored) { - } - } else { - final Matcher matcher = LEGACY_IOS_PATTERN.matcher(additionalSpecifiers); - - if (matcher.matches()) { - try { - iosVersion = new Semver(matcher.group(1), Semver.SemverType.LOOSE); - } catch (final SemverException ignored) { - } - } - } - - if (iosVersion != null && iosVersion.isGreaterThanOrEqualTo(MIN_IOS_VERSION) && iosVersion.isLowerThan(MAX_IOS_VERSION)) { - meterRegistry.counter(IOS_REQUEST_COUNTER_NAME, OS_TAG, iosVersion.toString()).increment(); - } - }); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsUtil.java deleted file mode 100644 index 1e845deba..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsUtil.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -public class MetricsUtil { - - private static final String PREFIX = "chat"; - - /** - * Returns a dot-separated ('.') name for the given class and name parts - */ - public static String name(Class clazz, String... parts) { - return name(clazz.getSimpleName(), parts); - } - - private static String name(String name, String... parts) { - final StringBuilder sb = new StringBuilder(PREFIX); - sb.append(".").append(name); - for (String part : parts) { - sb.append(".").append(part); - } - return sb.toString(); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MicrometerRegistryManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MicrometerRegistryManager.java deleted file mode 100644 index 4fc55043c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MicrometerRegistryManager.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import io.dropwizard.lifecycle.Managed; -import io.micrometer.core.instrument.MeterRegistry; - -public class MicrometerRegistryManager implements Managed { - - private final MeterRegistry meterRegistry; - - public MicrometerRegistryManager(final MeterRegistry meterRegistry) { - this.meterRegistry = meterRegistry; - } - - @Override - public void start() throws Exception { - - } - - @Override - public void stop() throws Exception { - // closing the registry publishes one final set of metrics - meterRegistry.close(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkGauge.java deleted file mode 100644 index 9b23f2a8f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkGauge.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - - -import com.codahale.metrics.Gauge; -import org.whispersystems.textsecuregcm.util.Pair; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; - -public abstract class NetworkGauge implements Gauge { - - protected Pair getSentReceived() throws IOException { - File proc = new File("/proc/net/dev"); - BufferedReader reader = new BufferedReader(new FileReader(proc)); - String header = reader.readLine(); - String header2 = reader.readLine(); - - long bytesSent = 0; - long bytesReceived = 0; - - String interfaceStats; - - while ((interfaceStats = reader.readLine()) != null) { - String[] stats = interfaceStats.split("\\s+"); - - if (!stats[1].equals("lo:")) { - bytesReceived += Long.parseLong(stats[2]); - bytesSent += Long.parseLong(stats[10]); - } - } - - return new Pair<>(bytesSent, bytesReceived); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkReceivedGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkReceivedGauge.java deleted file mode 100644 index 6ecd85ac4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkReceivedGauge.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Pair; - -import java.io.IOException; - -public class NetworkReceivedGauge extends NetworkGauge { - - private final Logger logger = LoggerFactory.getLogger(NetworkReceivedGauge.class); - - private long lastTimestamp; - private long lastReceived; - - public NetworkReceivedGauge() { - try { - this.lastTimestamp = System.currentTimeMillis(); - this.lastReceived = getSentReceived().second(); - } catch (IOException e) { - logger.warn(NetworkReceivedGauge.class.getSimpleName(), e); - } - } - - @Override - public Double getValue() { - try { - long timestamp = System.currentTimeMillis(); - Pair sentAndReceived = getSentReceived(); - double bytesReceived = sentAndReceived.second() - lastReceived; - double secondsElapsed = (timestamp - this.lastTimestamp) / 1000; - double result = bytesReceived / secondsElapsed; - - this.lastTimestamp = timestamp; - this.lastReceived = sentAndReceived.second(); - - return result; - } catch (IOException e) { - logger.warn("NetworkReceivedGauge", e); - return -1D; - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkSentGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkSentGauge.java deleted file mode 100644 index 4952550e7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/NetworkSentGauge.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Pair; - -import java.io.IOException; - -public class NetworkSentGauge extends NetworkGauge { - - private final Logger logger = LoggerFactory.getLogger(NetworkSentGauge.class); - - private long lastTimestamp; - private long lastSent; - - public NetworkSentGauge() { - try { - this.lastTimestamp = System.currentTimeMillis(); - this.lastSent = getSentReceived().first(); - } catch (IOException e) { - logger.warn(NetworkSentGauge.class.getSimpleName(), e); - } - } - - @Override - public Double getValue() { - try { - long timestamp = System.currentTimeMillis(); - Pair sentAndReceived = getSentReceived(); - double bytesTransmitted = sentAndReceived.first() - lastSent; - double secondsElapsed = (timestamp - this.lastTimestamp) / 1000; - double result = bytesTransmitted / secondsElapsed; - - this.lastSent = sentAndReceived.first(); - this.lastTimestamp = timestamp; - - return result; - } catch (IOException e) { - logger.warn("NetworkSentGauge", e); - return -1D; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/OperatingSystemMemoryGauge.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/OperatingSystemMemoryGauge.java deleted file mode 100644 index b3dcb6cb4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/OperatingSystemMemoryGauge.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import com.codahale.metrics.Gauge; -import com.google.common.annotations.VisibleForTesting; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -public class OperatingSystemMemoryGauge implements Gauge { - - private final String metricName; - - private static final File MEMINFO_FILE = new File("/proc/meminfo"); - private static final Pattern MEMORY_METRIC_PATTERN = Pattern.compile("^([^:]+):\\s+([0-9]+).*$"); - - public OperatingSystemMemoryGauge(final String metricName) { - this.metricName = metricName; - } - - @Override - public Long getValue() { - try (final BufferedReader bufferedReader = new BufferedReader(new FileReader(MEMINFO_FILE))) { - return getValue(bufferedReader.lines()); - } catch (final IOException e) { - return 0L; - } - } - - @VisibleForTesting - long getValue(final Stream lines) { - return lines.map(MEMORY_METRIC_PATTERN::matcher) - .filter(Matcher::matches) - .filter(matcher -> this.metricName.equalsIgnoreCase(matcher.group(1))) - .map(matcher -> Long.parseLong(matcher.group(2), 10)) - .findFirst() - .orElse(0L); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/ReportedMessageMetricsListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/ReportedMessageMetricsListener.java deleted file mode 100644 index 3c1c6eb85..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/ReportedMessageMetricsListener.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import static com.codahale.metrics.MetricRegistry.name; - -import io.micrometer.core.instrument.Metrics; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import net.logstash.logback.marker.Markers; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.storage.ReportedMessageListener; -import org.whispersystems.textsecuregcm.util.Util; - -public class ReportedMessageMetricsListener implements ReportedMessageListener { - - private final AccountsManager accountsManager; - - // ReportMessageManager name used deliberately to preserve continuity of metrics - private static final String REPORTED_COUNTER_NAME = name(ReportMessageManager.class, "reported"); - private static final String REPORTER_COUNTER_NAME = name(ReportMessageManager.class, "reporter"); - - private static final String COUNTRY_CODE_TAG_NAME = "countryCode"; - - private static final Logger logger = LoggerFactory.getLogger(ReportedMessageMetricsListener.class); - - public ReportedMessageMetricsListener(final AccountsManager accountsManager) { - this.accountsManager = accountsManager; - } - - @Override - public void handleMessageReported(final String sourceNumber, final UUID messageGuid, final UUID reporterUuid, - final Optional reportSpamToken) { - - final String sourceCountryCode = Util.getCountryCode(sourceNumber); - - Metrics.counter(REPORTED_COUNTER_NAME, COUNTRY_CODE_TAG_NAME, sourceCountryCode).increment(); - - accountsManager.getByAccountIdentifier(reporterUuid).ifPresent(reporter -> { - final String destinationCountryCode = Util.getCountryCode(reporter.getNumber()); - - logger.info(Markers.appendEntries(Map.of( - "sourceCountry", sourceCountryCode, - "destinationCountry", destinationCountryCode)), - "Message reported"); - - Metrics.counter(REPORTER_COUNTER_NAME, COUNTRY_CODE_TAG_NAME, destinationCountryCode).increment(); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/SignalDatadogReporterFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/SignalDatadogReporterFactory.java deleted file mode 100644 index 3338aef59..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/SignalDatadogReporterFactory.java +++ /dev/null @@ -1,89 +0,0 @@ -/* - * This is derived from Coursera's dropwizard datadog reporter. - * https://github.com/coursera/metrics-datadog - */ - -package org.whispersystems.textsecuregcm.metrics; - -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.ScheduledReporter; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonTypeName; -import io.dropwizard.metrics.BaseReporterFactory; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.List; -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import org.coursera.metrics.datadog.DatadogReporter; -import org.coursera.metrics.datadog.DatadogReporter.Expansion; -import org.coursera.metrics.datadog.DefaultMetricNameFormatterFactory; -import org.coursera.metrics.datadog.DynamicTagsCallbackFactory; -import org.coursera.metrics.datadog.MetricNameFormatterFactory; -import org.coursera.metrics.datadog.transport.AbstractTransportFactory; -import org.whispersystems.textsecuregcm.WhisperServerVersion; -import org.whispersystems.textsecuregcm.util.HostnameUtil; - -@JsonTypeName("signal-datadog") -public class SignalDatadogReporterFactory extends BaseReporterFactory { - - @JsonProperty - private List tags = null; - - @Valid - @JsonProperty - private DynamicTagsCallbackFactory dynamicTagsCallback = null; - - @JsonProperty - private String prefix = null; - - @Valid - @NotNull - @JsonProperty - private MetricNameFormatterFactory metricNameFormatter = new DefaultMetricNameFormatterFactory(); - - @Valid - @NotNull - @JsonProperty - private AbstractTransportFactory transport = null; - - private static final EnumSet EXPANSIONS = EnumSet.of( - Expansion.COUNT, - Expansion.MIN, - Expansion.MAX, - Expansion.MEAN, - Expansion.MEDIAN, - Expansion.P75, - Expansion.P95, - Expansion.P99, - Expansion.P999 - ); - - public ScheduledReporter build(MetricRegistry registry) { - final List tagsWithVersion; - - { - final String versionTag = "version:" + WhisperServerVersion.getServerVersion(); - - if (tags != null) { - tagsWithVersion = new ArrayList<>(tags); - tagsWithVersion.add(versionTag); - } else { - tagsWithVersion = List.of(versionTag); - } - } - - return DatadogReporter.forRegistry(registry) - .withTransport(transport.build()) - .withHost(HostnameUtil.getLocalHostname()) - .withTags(tagsWithVersion) - .withPrefix(prefix) - .withExpansions(EXPANSIONS) - .withMetricNameFormatter(metricNameFormatter.build()) - .withDynamicTagCallback(dynamicTagsCallback != null ? dynamicTagsCallback.build() : null) - .filter(getFilter()) - .convertDurationsTo(getDurationUnit()) - .convertRatesTo(getRateUnit()) - .build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/TrafficSource.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/TrafficSource.java deleted file mode 100644 index 303ffbdd1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/TrafficSource.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -public enum TrafficSource { - HTTP, - WEBSOCKET -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtil.java deleted file mode 100644 index a0cf321b1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtil.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import com.vdurmont.semver4j.Semver; -import io.micrometer.core.instrument.Tag; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; - -import java.util.EnumMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Utility class for extracting platform/version metrics tags from User-Agent strings. - */ -public class UserAgentTagUtil { - - public static final String PLATFORM_TAG = "platform"; - public static final String VERSION_TAG = "clientVersion"; - static final List OVERFLOW_TAGS = List.of(Tag.of(PLATFORM_TAG, "overflow"), Tag.of(VERSION_TAG, "overflow")); - static final List UNRECOGNIZED_TAGS = List.of(Tag.of(PLATFORM_TAG, "unrecognized"), Tag.of(VERSION_TAG, "unrecognized")); - - private static final Map MINIMUM_VERSION_BY_PLATFORM = new EnumMap<>(ClientPlatform.class); - - static { - MINIMUM_VERSION_BY_PLATFORM.put(ClientPlatform.ANDROID, new Semver("4.0.0")); - MINIMUM_VERSION_BY_PLATFORM.put(ClientPlatform.DESKTOP, new Semver("1.0.0")); - MINIMUM_VERSION_BY_PLATFORM.put(ClientPlatform.IOS, new Semver("3.0.0")); - } - - static final int MAX_VERSIONS = 1_000; - private static final Set> SEEN_VERSIONS = new HashSet<>(); - - private UserAgentTagUtil() { - } - - public static List getUserAgentTags(final String userAgentString) { - try { - final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString); - final List tags; - - if (userAgent.getVersion().isStable() && userAgent.getVersion().isGreaterThanOrEqualTo(MINIMUM_VERSION_BY_PLATFORM.get(userAgent.getPlatform()))) { - if (allowVersion(userAgent.getPlatform(), userAgent.getVersion())) { - tags = List.of(Tag.of(PLATFORM_TAG, userAgent.getPlatform().name().toLowerCase()), Tag.of(VERSION_TAG, userAgent.getVersion().toString())); - } else { - tags = OVERFLOW_TAGS; - } - } else { - tags = UNRECOGNIZED_TAGS; - } - - return tags; - } catch (final UnrecognizedUserAgentException e) { - return UNRECOGNIZED_TAGS; - } - } - - public static Tag getPlatformTag(final String userAgentString) { - String platform; - - try { - platform = UserAgentUtil.parseUserAgentString(userAgentString).getPlatform().name().toLowerCase(); - } catch (final UnrecognizedUserAgentException e) { - platform = "unrecognized"; - } - - return Tag.of(PLATFORM_TAG, platform); - } - - private static boolean allowVersion(final ClientPlatform platform, final Semver version) { - final Pair platformAndVersion = new Pair<>(platform, version); - - synchronized (SEEN_VERSIONS) { - return SEEN_VERSIONS.contains(platformAndVersion) || (SEEN_VERSIONS.size() < MAX_VERSIONS && SEEN_VERSIONS.add(platformAndVersion)); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/providers/MultiRecipientMessageProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/providers/MultiRecipientMessageProvider.java deleted file mode 100644 index 579f98eac..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/providers/MultiRecipientMessageProvider.java +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.providers; - -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.util.DataSizeUnit; -import java.io.IOException; -import java.io.InputStream; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.util.UUID; -import javax.ws.rs.BadRequestException; -import javax.ws.rs.Consumes; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.NoContentException; -import javax.ws.rs.ext.MessageBodyReader; -import javax.ws.rs.ext.Provider; -import org.whispersystems.textsecuregcm.entities.MultiRecipientMessage; - -@Provider -@Consumes(MultiRecipientMessageProvider.MEDIA_TYPE) -public class MultiRecipientMessageProvider implements MessageBodyReader { - - public static final String MEDIA_TYPE = "application/vnd.signal-messenger.mrm"; - public static final int MAX_RECIPIENT_COUNT = 5000; - public static final int MAX_MESSAGE_SIZE = Math.toIntExact(32 + DataSizeUnit.KIBIBYTES.toBytes(256)); - public static final byte VERSION = 0x22; - - @Override - public boolean isReadable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) { - return MEDIA_TYPE.equals(mediaType.toString()) && MultiRecipientMessage.class.isAssignableFrom(type); - } - - @Override - public MultiRecipientMessage readFrom(Class type, Type genericType, Annotation[] annotations, - MediaType mediaType, MultivaluedMap httpHeaders, InputStream entityStream) - throws IOException, WebApplicationException { - int versionByte = entityStream.read(); - if (versionByte == -1) { - throw new NoContentException("Empty body not allowed"); - } - if (versionByte != VERSION) { - throw new BadRequestException("Unsupported version"); - } - long count = readVarint(entityStream); - if (count > MAX_RECIPIENT_COUNT) { - throw new BadRequestException("Maximum recipient count exceeded"); - } - MultiRecipientMessage.Recipient[] recipients = new MultiRecipientMessage.Recipient[Math.toIntExact(count)]; - for (int i = 0; i < Math.toIntExact(count); i++) { - UUID uuid = readUuid(entityStream); - long deviceId = readVarint(entityStream); - int registrationId = readU16(entityStream); - byte[] perRecipientKeyMaterial = entityStream.readNBytes(48); - if (perRecipientKeyMaterial.length != 48) { - throw new IOException("Failed to read expected number of key material bytes for a recipient"); - } - recipients[i] = new MultiRecipientMessage.Recipient(uuid, deviceId, registrationId, perRecipientKeyMaterial); - } - - // caller is responsible for checking that the entity stream is at EOF when we return; if there are more bytes than - // this it'll return an error back. We just need to limit how many we'll accept here. - byte[] commonPayload = entityStream.readNBytes(MAX_MESSAGE_SIZE); - if (commonPayload.length < 32) { - throw new IOException("Failed to read expected number of common key material bytes"); - } - return new MultiRecipientMessage(recipients, commonPayload); - } - - /** - * Reads a UUID in network byte order and converts to a UUID object. - */ - private UUID readUuid(InputStream stream) throws IOException { - byte[] buffer = new byte[8]; - - int read = stream.readNBytes(buffer, 0, 8); - if (read != 8) { - throw new IOException("Insufficient bytes for UUID"); - } - long msb = convertNetworkByteOrderToLong(buffer); - - read = stream.readNBytes(buffer, 0, 8); - if (read != 8) { - throw new IOException("Insufficient bytes for UUID"); - } - long lsb = convertNetworkByteOrderToLong(buffer); - - return new UUID(msb, lsb); - } - - private long convertNetworkByteOrderToLong(byte[] buffer) { - long result = 0; - for (int i = 0; i < 8; i++) { - result = (result << 8) | (buffer[i] & 0xFFL); - } - return result; - } - - /** - * Reads a varint. A varint larger than 64 bits is rejected with a {@code WebApplicationException}. An - * {@code IOException} is thrown if the stream ends before we finish reading the varint. - * - * @return the varint value - */ - @VisibleForTesting - public static long readVarint(InputStream stream) throws IOException, WebApplicationException { - boolean hasMore = true; - int currentOffset = 0; - long result = 0; - while (hasMore) { - if (currentOffset >= 64) { - throw new BadRequestException("varint is too large"); - } - int b = stream.read(); - if (b == -1) { - throw new IOException("Missing byte " + (currentOffset / 7) + " of varint"); - } - if (currentOffset == 63 && (b & 0xFE) != 0) { - throw new BadRequestException("varint is too large"); - } - hasMore = (b & 0x80) != 0; - result |= (b & 0x7FL) << currentOffset; - currentOffset += 7; - } - return result; - } - - /** - * Reads two bytes with most significant byte first. Treats the value as unsigned so the range returned is - * {@code [0, 65535]}. - */ - @VisibleForTesting - static int readU16(InputStream stream) throws IOException { - int b1 = stream.read(); - if (b1 == -1) { - throw new IOException("Missing byte 1 of U16"); - } - int b2 = stream.read(); - if (b2 == -1) { - throw new IOException("Missing byte 2 of U16"); - } - return (b1 << 8) | b2; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/providers/RedisClientFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/providers/RedisClientFactory.java deleted file mode 100644 index 24ede2139..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/providers/RedisClientFactory.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.providers; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.dispatch.io.RedisPubSubConnectionFactory; -import org.whispersystems.dispatch.redis.PubSubConnection; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool; -import org.whispersystems.textsecuregcm.util.Util; - -import java.io.IOException; -import java.net.Socket; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.LinkedList; -import java.util.List; - -import redis.clients.jedis.JedisPool; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -public class RedisClientFactory implements RedisPubSubConnectionFactory { - - private final Logger logger = LoggerFactory.getLogger(RedisClientFactory.class); - - private final String host; - private final int port; - private final ReplicatedJedisPool jedisPool; - - public RedisClientFactory(String name, String url, List replicaUrls, CircuitBreakerConfiguration circuitBreakerConfiguration) - throws URISyntaxException - { - JedisPoolConfig poolConfig = new JedisPoolConfig(); - poolConfig.setTestOnBorrow(true); - poolConfig.setMaxWaitMillis(10000); - - URI redisURI = new URI(url); - - this.host = redisURI.getHost(); - this.port = redisURI.getPort(); - - JedisPool masterPool = new JedisPool(poolConfig, host, port, Protocol.DEFAULT_TIMEOUT, null); - List replicaPools = new LinkedList<>(); - - for (String replicaUrl : replicaUrls) { - URI replicaURI = new URI(replicaUrl); - - replicaPools.add(new JedisPool(poolConfig, replicaURI.getHost(), replicaURI.getPort(), - 500, Protocol.DEFAULT_TIMEOUT, null, - Protocol.DEFAULT_DATABASE, null, false, null , - null, null)); - } - - this.jedisPool = new ReplicatedJedisPool(name, masterPool, replicaPools, circuitBreakerConfiguration); - } - - public ReplicatedJedisPool getRedisClientPool() { - return jedisPool; - } - - @Override - public PubSubConnection connect() { - while (true) { - try { - Socket socket = new Socket(host, port); - return new PubSubConnection(socket); - } catch (IOException e) { - logger.warn("Error connecting", e); - Util.sleep(200); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/providers/RedisClusterHealthCheck.java b/service/src/main/java/org/whispersystems/textsecuregcm/providers/RedisClusterHealthCheck.java deleted file mode 100644 index bfef14dee..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/providers/RedisClusterHealthCheck.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.providers; - -import com.codahale.metrics.health.HealthCheck; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; - -public class RedisClusterHealthCheck extends HealthCheck { - - private final FaultTolerantRedisCluster redisCluster; - - public RedisClusterHealthCheck(final FaultTolerantRedisCluster redisCluster) { - this.redisCluster = redisCluster; - } - - @Override - protected Result check() { - redisCluster.withCluster(connection -> connection.sync().upstream().commands().ping()); - return Result.healthy(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java deleted file mode 100644 index 8eae39f2a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.push; - -import com.eatthepath.pushy.apns.ApnsClient; -import com.eatthepath.pushy.apns.ApnsClientBuilder; -import com.eatthepath.pushy.apns.DeliveryPriority; -import com.eatthepath.pushy.apns.PushType; -import com.eatthepath.pushy.apns.auth.ApnsSigningKey; -import com.eatthepath.pushy.apns.util.SimpleApnsPayloadBuilder; -import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.lifecycle.Managed; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -public class APNSender implements Managed, PushNotificationSender { - - private final ExecutorService executor; - private final String bundleId; - private final ApnsClient apnsClient; - - @VisibleForTesting - static final String APN_VOIP_NOTIFICATION_PAYLOAD = new SimpleApnsPayloadBuilder() - .setSound("default") - .setLocalizedAlertMessage("APN_Message") - .build(); - - @VisibleForTesting - static final String APN_NSE_NOTIFICATION_PAYLOAD = new SimpleApnsPayloadBuilder() - .setMutableContent(true) - .setLocalizedAlertMessage("APN_Message") - .build(); - - @VisibleForTesting - static final String APN_BACKGROUND_PAYLOAD = new SimpleApnsPayloadBuilder() - .setContentAvailable(true) - .build(); - - @VisibleForTesting - static final Instant MAX_EXPIRATION = Instant.ofEpochMilli(Integer.MAX_VALUE * 1000L); - - private static final String APNS_CA_FILENAME = "apns-certificates.pem"; - - private static final Timer SEND_NOTIFICATION_TIMER = Metrics.timer(name(APNSender.class, "sendNotification")); - - public APNSender(ExecutorService executor, ApnConfiguration configuration) - throws IOException, NoSuchAlgorithmException, InvalidKeyException - { - this.executor = executor; - this.bundleId = configuration.getBundleId(); - this.apnsClient = new ApnsClientBuilder().setSigningKey( - ApnsSigningKey.loadFromInputStream(new ByteArrayInputStream(configuration.getSigningKey().getBytes()), - configuration.getTeamId(), configuration.getKeyId())) - .setTrustedServerCertificateChain(getClass().getResourceAsStream(APNS_CA_FILENAME)) - .setApnsServer(configuration.isSandboxEnabled() ? ApnsClientBuilder.DEVELOPMENT_APNS_HOST : ApnsClientBuilder.PRODUCTION_APNS_HOST) - .build(); - } - - @VisibleForTesting - public APNSender(ExecutorService executor, ApnsClient apnsClient, String bundleId) { - this.executor = executor; - this.apnsClient = apnsClient; - this.bundleId = bundleId; - } - - @Override - public CompletableFuture sendNotification(final PushNotification notification) { - final String topic = switch (notification.tokenType()) { - case APN -> bundleId; - case APN_VOIP -> bundleId + ".voip"; - default -> throw new IllegalArgumentException("Unsupported token type: " + notification.tokenType()); - }; - - final boolean isVoip = notification.tokenType() == PushNotification.TokenType.APN_VOIP; - - final String payload = switch (notification.notificationType()) { - case NOTIFICATION -> { - if (isVoip) { - yield APN_VOIP_NOTIFICATION_PAYLOAD; - } else { - yield notification.urgent() ? APN_NSE_NOTIFICATION_PAYLOAD : APN_BACKGROUND_PAYLOAD; - } - } - - case CHALLENGE -> new SimpleApnsPayloadBuilder() - .setSound("default") - .setLocalizedAlertMessage("APN_Message") - .addCustomProperty("challenge", notification.data()) - .build(); - - case RATE_LIMIT_CHALLENGE -> new SimpleApnsPayloadBuilder() - .setSound("default") - .setLocalizedAlertMessage("APN_Message") - .addCustomProperty("rateLimitChallenge", notification.data()) - .build(); - }; - - final PushType pushType; - - if (isVoip) { - pushType = PushType.VOIP; - } else { - pushType = notification.urgent() ? PushType.ALERT : PushType.BACKGROUND; - } - - final DeliveryPriority deliveryPriority = - (notification.urgent() || isVoip) ? DeliveryPriority.IMMEDIATE : DeliveryPriority.CONSERVE_POWER; - - final String collapseId = - (notification.notificationType() == PushNotification.NotificationType.NOTIFICATION && notification.urgent() && !isVoip) - ? "incoming-message" : null; - - final Instant start = Instant.now(); - - return apnsClient.sendNotification(new SimpleApnsPushNotification(notification.deviceToken(), - topic, - payload, - MAX_EXPIRATION, - deliveryPriority, - pushType, - collapseId)) - .whenComplete((response, throwable) -> { - // Note that we deliberately run this small bit of non-blocking measurement on the "send notification" thread - // to avoid any measurement noise that could arise from dispatching to another executor and waiting in its - // queue - SEND_NOTIFICATION_TIMER.record(Duration.between(start, Instant.now())); - }) - .thenApplyAsync(response -> { - final boolean accepted; - final String rejectionReason; - final boolean unregistered; - - if (response.isAccepted()) { - accepted = true; - rejectionReason = null; - unregistered = false; - } else { - accepted = false; - rejectionReason = response.getRejectionReason().orElse("unknown"); - unregistered = ("Unregistered".equals(rejectionReason) || "BadDeviceToken".equals(rejectionReason)); - } - - return new SendPushNotificationResult(accepted, rejectionReason, unregistered); - }, executor); - } - - @Override - public void start() { - } - - @Override - public void stop() { - this.apnsClient.close().join(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnPushNotificationScheduler.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnPushNotificationScheduler.java deleted file mode 100644 index 10654059b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/ApnPushNotificationScheduler.java +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.lifecycle.Managed; -import io.lettuce.core.Limit; -import io.lettuce.core.Range; -import io.lettuce.core.ScriptOutputType; -import io.lettuce.core.SetArgs; -import io.lettuce.core.cluster.SlotHash; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import java.io.IOException; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.RedisClusterUtil; -import org.whispersystems.textsecuregcm.util.Util; - -public class ApnPushNotificationScheduler implements Managed { - - private static final Logger logger = LoggerFactory.getLogger(ApnPushNotificationScheduler.class); - - private static final String PENDING_RECURRING_VOIP_NOTIFICATIONS_KEY_PREFIX = "PENDING_APN"; - private static final String PENDING_BACKGROUND_NOTIFICATIONS_KEY_PREFIX = "PENDING_BACKGROUND_APN"; - private static final String LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX = "LAST_BACKGROUND_NOTIFICATION"; - - @VisibleForTesting - static final String NEXT_SLOT_TO_PROCESS_KEY = "pending_notification_next_slot"; - - private static final Counter delivered = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_delivered")); - private static final Counter sent = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_sent")); - private static final Counter retry = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_retry")); - private static final Counter evicted = Metrics.counter(name(ApnPushNotificationScheduler.class, "voip_evicted")); - - private static final Counter backgroundNotificationScheduledCounter = Metrics.counter(name(ApnPushNotificationScheduler.class, "backgroundNotification", "scheduled")); - private static final Counter backgroundNotificationSentCounter = Metrics.counter(name(ApnPushNotificationScheduler.class, "backgroundNotification", "sent")); - - private final APNSender apnSender; - private final AccountsManager accountsManager; - private final FaultTolerantRedisCluster pushSchedulingCluster; - private final Clock clock; - - private final ClusterLuaScript getPendingVoipDestinationsScript; - private final ClusterLuaScript insertPendingVoipDestinationScript; - private final ClusterLuaScript removePendingVoipDestinationScript; - - private final ClusterLuaScript scheduleBackgroundNotificationScript; - - private final Thread[] workerThreads = new Thread[WORKER_THREAD_COUNT]; - - private static final int WORKER_THREAD_COUNT = 4; - - @VisibleForTesting - static final Duration BACKGROUND_NOTIFICATION_PERIOD = Duration.ofMinutes(20); - - private final AtomicBoolean running = new AtomicBoolean(false); - - class NotificationWorker implements Runnable { - - private static final int PAGE_SIZE = 128; - - @Override - public void run() { - while (running.get()) { - try { - final long entriesProcessed = processNextSlot(); - - if (entriesProcessed == 0) { - Util.sleep(1000); - } - } catch (Exception e) { - logger.warn("Exception while operating", e); - } - } - } - - private long processNextSlot() { - final int slot = (int) (pushSchedulingCluster.withCluster(connection -> - connection.sync().incr(NEXT_SLOT_TO_PROCESS_KEY)) % SlotHash.SLOT_COUNT); - - return processRecurringVoipNotifications(slot) + processScheduledBackgroundNotifications(slot); - } - - @VisibleForTesting - long processRecurringVoipNotifications(final int slot) { - List pendingDestinations; - long entriesProcessed = 0; - - do { - pendingDestinations = getPendingDestinationsForRecurringVoipNotifications(slot, PAGE_SIZE); - entriesProcessed += pendingDestinations.size(); - - for (final String destination : pendingDestinations) { - try { - getAccountAndDeviceFromPairString(destination).ifPresentOrElse( - accountAndDevice -> sendRecurringVoipNotification(accountAndDevice.first(), accountAndDevice.second()), - () -> removeRecurringVoipNotificationEntry(destination)); - } catch (final IllegalArgumentException e) { - logger.warn("Failed to parse account/device pair: {}", destination, e); - } - } - } while (!pendingDestinations.isEmpty()); - - return entriesProcessed; - } - - @VisibleForTesting - long processScheduledBackgroundNotifications(final int slot) { - final long currentTimeMillis = clock.millis(); - final String queueKey = getPendingBackgroundNotificationQueueKey(slot); - - final long processedBackgroundNotifications = pushSchedulingCluster.withCluster(connection -> { - List destinations; - long offset = 0; - - do { - destinations = connection.sync().zrangebyscore(queueKey, Range.create(0, currentTimeMillis), Limit.create(offset, PAGE_SIZE)); - - for (final String destination : destinations) { - try { - getAccountAndDeviceFromPairString(destination).ifPresent(accountAndDevice -> - sendBackgroundNotification(accountAndDevice.first(), accountAndDevice.second())); - } catch (final IllegalArgumentException e) { - logger.warn("Failed to parse account/device pair: {}", destination, e); - } - } - - offset += destinations.size(); - } while (destinations.size() == PAGE_SIZE); - - return offset; - }); - - pushSchedulingCluster.useCluster(connection -> - connection.sync().zremrangebyscore(queueKey, Range.create(0, currentTimeMillis))); - - return processedBackgroundNotifications; - } - } - - public ApnPushNotificationScheduler(FaultTolerantRedisCluster pushSchedulingCluster, - APNSender apnSender, - AccountsManager accountsManager) throws IOException { - - this(pushSchedulingCluster, apnSender, accountsManager, Clock.systemUTC()); - } - - @VisibleForTesting - ApnPushNotificationScheduler(FaultTolerantRedisCluster pushSchedulingCluster, - APNSender apnSender, - AccountsManager accountsManager, - Clock clock) throws IOException { - - this.apnSender = apnSender; - this.accountsManager = accountsManager; - this.pushSchedulingCluster = pushSchedulingCluster; - this.clock = clock; - - this.getPendingVoipDestinationsScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/get.lua", ScriptOutputType.MULTI); - this.insertPendingVoipDestinationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/insert.lua", ScriptOutputType.VALUE); - this.removePendingVoipDestinationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/remove.lua", ScriptOutputType.INTEGER); - - this.scheduleBackgroundNotificationScript = ClusterLuaScript.fromResource(pushSchedulingCluster, "lua/apn/schedule_background_notification.lua", ScriptOutputType.VALUE); - - for (int i = 0; i < this.workerThreads.length; i++) { - this.workerThreads[i] = new Thread(new NotificationWorker(), "ApnFallbackManagerWorker-" + i); - } - } - - void scheduleRecurringVoipNotification(Account account, Device device) { - sent.increment(); - insertRecurringVoipNotificationEntry(account, device, clock.millis() + (15 * 1000), (15 * 1000)); - } - - void scheduleBackgroundNotification(final Account account, final Device device) { - backgroundNotificationScheduledCounter.increment(); - - scheduleBackgroundNotificationScript.execute( - List.of( - getLastBackgroundNotificationTimestampKey(account, device), - getPendingBackgroundNotificationQueueKey(account, device)), - List.of( - getPairString(account, device), - String.valueOf(clock.millis()), - String.valueOf(BACKGROUND_NOTIFICATION_PERIOD.toMillis()))); - } - - public void cancelScheduledNotifications(Account account, Device device) { - if (removeRecurringVoipNotificationEntry(account, device)) { - delivered.increment(); - } - - pushSchedulingCluster.useCluster(connection -> - connection.sync().zrem(getPendingBackgroundNotificationQueueKey(account, device), getPairString(account, device))); - } - - @Override - public synchronized void start() { - running.set(true); - - for (final Thread workerThread : workerThreads) { - workerThread.start(); - } - } - - @Override - public synchronized void stop() throws InterruptedException { - running.set(false); - - for (final Thread workerThread : workerThreads) { - workerThread.join(); - } - } - - private void sendRecurringVoipNotification(final Account account, final Device device) { - String apnId = device.getVoipApnId(); - - if (apnId == null) { - removeRecurringVoipNotificationEntry(account, device); - return; - } - - long deviceLastSeen = device.getLastSeen(); - - if (deviceLastSeen < clock.millis() - TimeUnit.DAYS.toMillis(7)) { - evicted.increment(); - removeRecurringVoipNotificationEntry(account, device); - return; - } - - apnSender.sendNotification(new PushNotification(apnId, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device, true)); - retry.increment(); - } - - @VisibleForTesting - void sendBackgroundNotification(final Account account, final Device device) { - if (StringUtils.isNotBlank(device.getApnId())) { - // It's okay for the "last notification" timestamp to expire after the "cooldown" period has elapsed; a missing - // timestamp and a timestamp older than the period are functionally equivalent. - pushSchedulingCluster.useCluster(connection -> connection.sync().set( - getLastBackgroundNotificationTimestampKey(account, device), - String.valueOf(clock.millis()), new SetArgs().ex(BACKGROUND_NOTIFICATION_PERIOD))); - - apnSender.sendNotification(new PushNotification(device.getApnId(), PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, false)); - - backgroundNotificationSentCounter.increment(); - } - } - - @VisibleForTesting - static Optional> getSeparated(String encoded) { - try { - if (encoded == null) return Optional.empty(); - - String[] parts = encoded.split(":"); - - if (parts.length != 2) { - logger.warn("Got strange encoded number: " + encoded); - return Optional.empty(); - } - - return Optional.of(new Pair<>(parts[0], Long.parseLong(parts[1]))); - } catch (NumberFormatException e) { - logger.warn("Badly formatted: " + encoded, e); - return Optional.empty(); - } - } - - @VisibleForTesting - static String getPairString(final Account account, final Device device) { - return account.getUuid() + ":" + device.getId(); - } - - @VisibleForTesting - Optional> getAccountAndDeviceFromPairString(final String endpoint) { - try { - if (StringUtils.isBlank(endpoint)) { - throw new IllegalArgumentException("Endpoint must not be blank"); - } - - final String[] parts = endpoint.split(":"); - - if (parts.length != 2) { - throw new IllegalArgumentException("Could not parse endpoint string: " + endpoint); - } - - final Optional maybeAccount = accountsManager.getByAccountIdentifier(UUID.fromString(parts[0])); - - return maybeAccount.flatMap(account -> account.getDevice(Long.parseLong(parts[1]))) - .map(device -> new Pair<>(maybeAccount.get(), device)); - - } catch (final NumberFormatException e) { - throw new IllegalArgumentException(e); - } - } - - private boolean removeRecurringVoipNotificationEntry(Account account, Device device) { - return removeRecurringVoipNotificationEntry(getEndpointKey(account, device)); - } - - private boolean removeRecurringVoipNotificationEntry(final String endpoint) { - return (long) removePendingVoipDestinationScript.execute( - List.of(getPendingRecurringVoipNotificationQueueKey(endpoint), endpoint), - Collections.emptyList()) > 0; - } - - @SuppressWarnings("unchecked") - @VisibleForTesting - List getPendingDestinationsForRecurringVoipNotifications(final int slot, final int limit) { - return (List) getPendingVoipDestinationsScript.execute( - List.of(getPendingRecurringVoipNotificationQueueKey(slot)), - List.of(String.valueOf(clock.millis()), String.valueOf(limit))); - } - - private void insertRecurringVoipNotificationEntry(final Account account, final Device device, final long timestamp, final long interval) { - final String endpoint = getEndpointKey(account, device); - - insertPendingVoipDestinationScript.execute( - List.of(getPendingRecurringVoipNotificationQueueKey(endpoint), endpoint), - List.of(String.valueOf(timestamp), - String.valueOf(interval), - account.getUuid().toString(), - String.valueOf(device.getId()))); - } - - @VisibleForTesting - static String getEndpointKey(final Account account, final Device device) { - return "apn_device::{" + account.getUuid() + "::" + device.getId() + "}"; - } - - private static String getPendingRecurringVoipNotificationQueueKey(final String endpoint) { - return getPendingRecurringVoipNotificationQueueKey(SlotHash.getSlot(endpoint)); - } - - private static String getPendingRecurringVoipNotificationQueueKey(final int slot) { - return PENDING_RECURRING_VOIP_NOTIFICATIONS_KEY_PREFIX + "::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}"; - } - - @VisibleForTesting - static String getPendingBackgroundNotificationQueueKey(final Account account, final Device device) { - return getPendingBackgroundNotificationQueueKey(SlotHash.getSlot(getPairString(account, device))); - } - - private static String getPendingBackgroundNotificationQueueKey(final int slot) { - return PENDING_BACKGROUND_NOTIFICATIONS_KEY_PREFIX + "::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}"; - } - - private static String getLastBackgroundNotificationTimestampKey(final Account account, final Device device) { - return LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX + "::{" + getPairString(account, device) + "}"; - } - - @VisibleForTesting - Optional getLastBackgroundNotificationTimestamp(final Account account, final Device device) { - return Optional.ofNullable( - pushSchedulingCluster.withCluster(connection -> - connection.sync().get(getLastBackgroundNotificationTimestampKey(account, device)))) - .map(timestampString -> Instant.ofEpochMilli(Long.parseLong(timestampString))); - } - - @VisibleForTesting - Optional getNextScheduledBackgroundNotificationTimestamp(final Account account, final Device device) { - return Optional.ofNullable( - pushSchedulingCluster.withCluster(connection -> - connection.sync().zscore(getPendingBackgroundNotificationQueueKey(account, device), - getPairString(account, device)))) - .map(timestamp -> Instant.ofEpochMilli(timestamp.longValue())); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/ClientPresenceManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/ClientPresenceManager.java deleted file mode 100644 index 5495dc59b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/ClientPresenceManager.java +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.lifecycle.Managed; -import io.lettuce.core.LettuceFutures; -import io.lettuce.core.RedisFuture; -import io.lettuce.core.ScriptOutputType; -import io.lettuce.core.cluster.SlotHash; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import io.lettuce.core.cluster.event.ClusterTopologyChangedEvent; -import io.lettuce.core.cluster.models.partitions.RedisClusterNode; -import io.lettuce.core.cluster.pubsub.RedisClusterPubSubAdapter; -import java.io.IOException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; -import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.Constants; - -/** - * The client presence manager keeps track of which clients are actively connected and "present" to receive messages. - * Only one client per account/device may be present at a time; if a second client for the same account/device declares - * its presence, the previous client is displaced. - *

- * The client presence manager depends on Redis keyspace notifications and requires that the Redis instance support at - * least the following notification types: {@code K$z}. - */ -public class ClientPresenceManager extends RedisClusterPubSubAdapter implements Managed { - - private final String managerId = UUID.randomUUID().toString(); - private final String connectedClientSetKey = getConnectedClientSetKey(managerId); - - private final FaultTolerantRedisCluster presenceCluster; - private final FaultTolerantPubSubConnection pubSubConnection; - - private final ClusterLuaScript clearPresenceScript; - private final ClusterLuaScript renewPresenceScript; - - private final ExecutorService keyspaceNotificationExecutorService; - private final ScheduledExecutorService scheduledExecutorService; - private ScheduledFuture pruneMissingPeersFuture; - - private final Map displacementListenersByPresenceKey = new ConcurrentHashMap<>(); - - private final Timer checkPresenceTimer; - private final Timer setPresenceTimer; - private final Timer clearPresenceTimer; - private final Timer prunePeersTimer; - private final Meter pruneClientMeter; - private final Meter remoteDisplacementMeter; - private final Meter pubSubMessageMeter; - - private static final int PRUNE_PEERS_INTERVAL_SECONDS = (int) Duration.ofSeconds(30).toSeconds(); - private static final int PRESENCE_EXPIRATION_SECONDS = (int) Duration.ofMinutes(11).toSeconds(); - - static final String MANAGER_SET_KEY = "presence::managers"; - - private static final Logger log = LoggerFactory.getLogger(ClientPresenceManager.class); - - public ClientPresenceManager(final FaultTolerantRedisCluster presenceCluster, - final ScheduledExecutorService scheduledExecutorService, - final ExecutorService keyspaceNotificationExecutorService) throws IOException { - this.presenceCluster = presenceCluster; - this.pubSubConnection = this.presenceCluster.createPubSubConnection(); - this.clearPresenceScript = ClusterLuaScript.fromResource(presenceCluster, "lua/clear_presence.lua", ScriptOutputType.INTEGER); - this.renewPresenceScript = ClusterLuaScript.fromResource(presenceCluster, "lua/renew_presence.lua", ScriptOutputType.VALUE); - this.scheduledExecutorService = scheduledExecutorService; - this.keyspaceNotificationExecutorService = keyspaceNotificationExecutorService; - - final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - metricRegistry.gauge(name(getClass(), "localClientCount"), () -> displacementListenersByPresenceKey::size); - - this.checkPresenceTimer = metricRegistry.timer(name(getClass(), "checkPresence")); - this.setPresenceTimer = metricRegistry.timer(name(getClass(), "setPresence")); - this.clearPresenceTimer = metricRegistry.timer(name(getClass(), "clearPresence")); - this.prunePeersTimer = metricRegistry.timer(name(getClass(), "prunePeers")); - this.pruneClientMeter = metricRegistry.meter(name(getClass(), "pruneClient")); - this.remoteDisplacementMeter = metricRegistry.meter(name(getClass(), "remoteDisplacement")); - this.pubSubMessageMeter = metricRegistry.meter(name(getClass(), "pubSubMessage")); - } - - @VisibleForTesting - FaultTolerantPubSubConnection getPubSubConnection() { - return pubSubConnection; - } - - @Override - public void start() { - pubSubConnection.usePubSubConnection(connection -> { - connection.addListener(this); - connection.getResources().eventBus().get() - .filter(event -> event instanceof ClusterTopologyChangedEvent) - .subscribe(event -> resubscribeAll()); - - final String presenceChannel = getManagerPresenceChannel(managerId); - final int slot = SlotHash.getSlot(presenceChannel); - - connection.sync().nodes(node -> node.is(RedisClusterNode.NodeFlag.UPSTREAM) && node.hasSlot(slot)) - .commands() - .subscribe(presenceChannel); - }); - - presenceCluster.useCluster(connection -> connection.sync().sadd(MANAGER_SET_KEY, managerId)); - - pruneMissingPeersFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { - try { - pruneMissingPeers(); - } catch (final Throwable t) { - log.warn("Failed to prune missing peers", t); - } - }, new Random().nextInt(PRUNE_PEERS_INTERVAL_SECONDS), PRUNE_PEERS_INTERVAL_SECONDS, TimeUnit.SECONDS); - } - - @Override - public void stop() { - pubSubConnection.usePubSubConnection(connection -> connection.removeListener(this)); - - if (pruneMissingPeersFuture != null) { - pruneMissingPeersFuture.cancel(false); - } - - for (final String presenceKey : displacementListenersByPresenceKey.keySet()) { - clearPresence(presenceKey); - } - - presenceCluster.useCluster(connection -> { - connection.sync().srem(MANAGER_SET_KEY, managerId); - connection.sync().del(getConnectedClientSetKey(managerId)); - }); - - pubSubConnection.usePubSubConnection( - connection -> connection.sync().upstream().commands().unsubscribe(getManagerPresenceChannel(managerId))); - } - - public void setPresent(final UUID accountUuid, final long deviceId, final DisplacedPresenceListener displacementListener) { - - try (final Timer.Context ignored = setPresenceTimer.time()) { - final String presenceKey = getPresenceKey(accountUuid, deviceId); - - displacePresence(presenceKey, true); - - displacementListenersByPresenceKey.put(presenceKey, displacementListener); - - presenceCluster.useCluster(connection -> { - final RedisAdvancedClusterCommands commands = connection.sync(); - - commands.sadd(connectedClientSetKey, presenceKey); - commands.setex(presenceKey, PRESENCE_EXPIRATION_SECONDS, managerId); - }); - - subscribeForRemotePresenceChanges(presenceKey); - } - } - - public void renewPresence(final UUID accountUuid, final long deviceId) { - renewPresenceScript.execute(List.of(getPresenceKey(accountUuid, deviceId)), - List.of(managerId, String.valueOf(PRESENCE_EXPIRATION_SECONDS))); - } - - public void disconnectAllPresences(final UUID accountUuid, final List deviceIds) { - - List presenceKeys = new ArrayList<>(); - deviceIds.forEach(deviceId -> { - String presenceKey = getPresenceKey(accountUuid, deviceId); - if (isLocallyPresent(accountUuid, deviceId)) { - displacePresence(presenceKey, false); - } - presenceKeys.add(presenceKey); - }); - - presenceCluster.useCluster(connection -> { - List> futures = presenceKeys.stream().map(key -> connection.async().del(key)).toList(); - LettuceFutures.awaitAll(connection.getTimeout(), futures.toArray(new RedisFuture[0])); - }); - } - - public void disconnectPresence(final UUID accountUuid, final long deviceId) { - disconnectAllPresences(accountUuid, List.of(deviceId)); - } - - private void displacePresence(final String presenceKey, final boolean connectedElsewhere) { - final DisplacedPresenceListener displacementListener = displacementListenersByPresenceKey.get(presenceKey); - - if (displacementListener != null) { - displacementListener.handleDisplacement(connectedElsewhere); - } - - clearPresence(presenceKey); - } - - public boolean isPresent(final UUID accountUuid, final long deviceId) { - try (final Timer.Context ignored = checkPresenceTimer.time()) { - return presenceCluster.withCluster(connection -> - connection.sync().exists(getPresenceKey(accountUuid, deviceId))) == 1; - } - } - - public boolean isLocallyPresent(final UUID accountUuid, final long deviceId) { - return displacementListenersByPresenceKey.containsKey(getPresenceKey(accountUuid, deviceId)); - } - - public boolean clearPresence(final UUID accountUuid, final long deviceId) { - return clearPresence(getPresenceKey(accountUuid, deviceId)); - } - - private boolean clearPresence(final String presenceKey) { - try (final Timer.Context ignored = clearPresenceTimer.time()) { - displacementListenersByPresenceKey.remove(presenceKey); - unsubscribeFromRemotePresenceChanges(presenceKey); - - final boolean removed = clearPresenceScript.execute(List.of(presenceKey), List.of(managerId)) != null; - presenceCluster.useCluster(connection -> connection.sync().srem(connectedClientSetKey, presenceKey)); - - return removed; - } - } - - private void subscribeForRemotePresenceChanges(final String presenceKey) { - final int slot = SlotHash.getSlot(presenceKey); - - pubSubConnection.usePubSubConnection( - connection -> connection.sync().nodes(node -> node.is(RedisClusterNode.NodeFlag.UPSTREAM) && node.hasSlot(slot)) - .commands() - .subscribe(getKeyspaceNotificationChannel(presenceKey))); - } - - private void resubscribeAll() { - for (final String presenceKey : displacementListenersByPresenceKey.keySet()) { - subscribeForRemotePresenceChanges(presenceKey); - } - } - - private void unsubscribeFromRemotePresenceChanges(final String presenceKey) { - pubSubConnection.usePubSubConnection( - connection -> connection.sync().upstream().commands().unsubscribe(getKeyspaceNotificationChannel(presenceKey))); - } - - void pruneMissingPeers() { - try (final Timer.Context ignored = prunePeersTimer.time()) { - final Set peerIds = presenceCluster.withCluster( - connection -> connection.sync().smembers(MANAGER_SET_KEY)); - peerIds.remove(managerId); - - for (final String peerId : peerIds) { - final boolean peerMissing = presenceCluster.withCluster( - connection -> connection.sync().publish(getManagerPresenceChannel(peerId), "ping") == 0); - - if (peerMissing) { - log.debug("Presence manager {} did not respond to ping", peerId); - - final String connectedClientsKey = getConnectedClientSetKey(peerId); - - String presenceKey; - - while ((presenceKey = presenceCluster.withCluster(connection -> connection.sync().spop(connectedClientsKey))) - != null) { - clearPresenceScript.execute(List.of(presenceKey), List.of(peerId)); - pruneClientMeter.mark(); - } - - presenceCluster.useCluster(connection -> { - connection.sync().del(connectedClientsKey); - connection.sync().srem(MANAGER_SET_KEY, peerId); - }); - } - } - } - } - - @Override - public void message(final RedisClusterNode node, final String channel, final String message) { - pubSubMessageMeter.mark(); - - if (channel.startsWith("__keyspace@0__:presence::{")) { - if ("set".equals(message) || "del".equals(message)) { - // for "set", another process has overwritten this presence key, which means the client has connected to another host. - // for "del", another process has indicated the client should be disconnected - final boolean connectedElsewhere = "set".equals(message); - - // At this point, we're on a Lettuce IO thread and need to dispatch to a separate thread before making - // synchronous Lettuce calls to avoid deadlocking. - keyspaceNotificationExecutorService.execute(() -> { - try { - displacePresence(channel.substring("__keyspace@0__:".length()), connectedElsewhere); - remoteDisplacementMeter.mark(); - } catch (final Exception e) { - log.warn("Error displacing presence", e); - } - }); - } - } - } - - @VisibleForTesting - String getManagerId() { - return managerId; - } - - @VisibleForTesting - static String getPresenceKey(final UUID accountUuid, final long deviceId) { - return "presence::{" + accountUuid.toString() + "::" + deviceId + "}"; - } - - private static String getKeyspaceNotificationChannel(final String presenceKey) { - return "__keyspace@0__:" + presenceKey; - } - - @VisibleForTesting - static String getConnectedClientSetKey(final String managerId) { - return "presence::clients::" + managerId; - } - - @VisibleForTesting - static String getManagerPresenceChannel(final String managerId) { - return "presence::manager::" + managerId; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/DisplacedPresenceListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/DisplacedPresenceListener.java deleted file mode 100644 index 88258be70..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/DisplacedPresenceListener.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -/** - * A displaced presence listener is notified when a specific client's presence has been displaced because the same - * client opened a newer connection to the Signal service. - */ -@FunctionalInterface -public interface DisplacedPresenceListener { - - void handleDisplacement(boolean connectedElsewhere); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java deleted file mode 100644 index f13b72b28..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.api.core.ApiFuture; -import com.google.api.core.ApiFutureCallback; -import com.google.api.core.ApiFutures; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; -import com.google.firebase.ThreadManager; -import com.google.firebase.messaging.AndroidConfig; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.MessagingErrorCode; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.ThreadFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class FcmSender implements PushNotificationSender { - - private final ExecutorService executor; - private final FirebaseMessaging firebaseMessagingClient; - - private static final Timer SEND_NOTIFICATION_TIMER = Metrics.timer(name(FcmSender.class, "sendNotification")); - - private static final Logger logger = LoggerFactory.getLogger(FcmSender.class); - - public FcmSender(ExecutorService executor, String credentials) throws IOException { - try (final ByteArrayInputStream credentialInputStream = new ByteArrayInputStream(credentials.getBytes(StandardCharsets.UTF_8))) { - FirebaseApp.initializeApp(FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(credentialInputStream)) - .setThreadManager(new ThreadManager() { - @Override - protected ExecutorService getExecutor(final FirebaseApp app) { - return executor; - } - - @Override - protected void releaseExecutor(final FirebaseApp app, final ExecutorService executor) { - // Do nothing; the executor service is managed by Dropwizard - } - - @Override - protected ThreadFactory getThreadFactory() { - return new ThreadFactoryBuilder() - .setNameFormat("firebase-%d") - .build(); - } - }) - .build()); - } - - this.executor = executor; - this.firebaseMessagingClient = FirebaseMessaging.getInstance(); - } - - @VisibleForTesting - public FcmSender(ExecutorService executor, FirebaseMessaging firebaseMessagingClient) { - this.executor = executor; - this.firebaseMessagingClient = firebaseMessagingClient; - } - - @Override - public CompletableFuture sendNotification(PushNotification pushNotification) { - Message.Builder builder = Message.builder() - .setToken(pushNotification.deviceToken()) - .setAndroidConfig(AndroidConfig.builder() - .setPriority(pushNotification.urgent() ? AndroidConfig.Priority.HIGH : AndroidConfig.Priority.NORMAL) - .build()); - - final String key = switch (pushNotification.notificationType()) { - case NOTIFICATION -> "notification"; - case CHALLENGE -> "challenge"; - case RATE_LIMIT_CHALLENGE -> "rateLimitChallenge"; - }; - - builder.putData(key, pushNotification.data() != null ? pushNotification.data() : ""); - - final Instant start = Instant.now(); - final CompletableFuture completableSendFuture = new CompletableFuture<>(); - - final ApiFuture sendFuture = firebaseMessagingClient.sendAsync(builder.build()); - - // We want to record the time taken to send the push notification as directly as possible; executing this very small - // bit of non-blocking measurement on the sender thread lets us do that without picking up any confounding factors - // like having a callback waiting in an executor's queue. - sendFuture.addListener(() -> SEND_NOTIFICATION_TIMER.record(Duration.between(start, Instant.now())), - MoreExecutors.directExecutor()); - - ApiFutures.addCallback(sendFuture, new ApiFutureCallback<>() { - @Override - public void onSuccess(final String result) { - completableSendFuture.complete(new SendPushNotificationResult(true, null, false)); - } - - @Override - public void onFailure(final Throwable cause) { - if (cause instanceof final FirebaseMessagingException firebaseMessagingException) { - final String errorCode; - - if (firebaseMessagingException.getMessagingErrorCode() != null) { - errorCode = firebaseMessagingException.getMessagingErrorCode().name(); - } else { - logger.warn("Received an FCM exception with no error code", firebaseMessagingException); - errorCode = "unknown"; - } - - completableSendFuture.complete(new SendPushNotificationResult(false, - errorCode, - firebaseMessagingException.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED)); - } else { - completableSendFuture.completeExceptionally(cause); - } - } - }, executor); - - return completableSendFuture; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java deleted file mode 100644 index 60e839c6c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.push; - -import static com.codahale.metrics.MetricRegistry.name; -import static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; - -import io.micrometer.core.instrument.Metrics; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.redis.RedisOperation; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.MessagesManager; - -/** - * A MessageSender sends Signal messages to destination devices. Messages may be "normal" user-to-user messages, - * ephemeral ("online") messages like typing indicators, or delivery receipts. - *

- * If a client is not actively connected to a Signal server to receive a message as soon as it is sent, the - * MessageSender will send a push notification to the destination device if possible. Some messages may be designated - * for "online" delivery only and will not be delivered (and clients will not be notified) if the destination device - * isn't actively connected to a Signal server. - * - * @see ClientPresenceManager - * @see org.whispersystems.textsecuregcm.storage.MessageAvailabilityListener - * @see ReceiptSender - */ -public class MessageSender { - - private final ClientPresenceManager clientPresenceManager; - private final MessagesManager messagesManager; - private final PushNotificationManager pushNotificationManager; - private final PushLatencyManager pushLatencyManager; - - private static final String SEND_COUNTER_NAME = name(MessageSender.class, "sendMessage"); - private static final String CHANNEL_TAG_NAME = "channel"; - private static final String EPHEMERAL_TAG_NAME = "ephemeral"; - private static final String CLIENT_ONLINE_TAG_NAME = "clientOnline"; - private static final String URGENT_TAG_NAME = "urgent"; - private static final String STORY_TAG_NAME = "story"; - private static final String SEALED_SENDER_TAG_NAME = "sealedSender"; - private static final String HAS_SPAM_REPORTING_TOKEN_TAG_NAME = "hasSpamReportingToken"; - - public MessageSender(ClientPresenceManager clientPresenceManager, - MessagesManager messagesManager, - PushNotificationManager pushNotificationManager, - PushLatencyManager pushLatencyManager) { - this.clientPresenceManager = clientPresenceManager; - this.messagesManager = messagesManager; - this.pushNotificationManager = pushNotificationManager; - this.pushLatencyManager = pushLatencyManager; - } - - public void sendMessage(final Account account, final Device device, final Envelope message, final boolean online) - throws NotPushRegisteredException { - - final String channel; - - if (device.getGcmId() != null) { - channel = "gcm"; - } else if (device.getApnId() != null) { - channel = "apn"; - } else if (device.getFetchesMessages()) { - channel = "websocket"; - } else { - throw new AssertionError(); - } - - final boolean clientPresent; - - if (online) { - clientPresent = clientPresenceManager.isPresent(account.getUuid(), device.getId()); - - if (clientPresent) { - messagesManager.insert(account.getUuid(), device.getId(), message.toBuilder().setEphemeral(true).build()); - } - } else { - messagesManager.insert(account.getUuid(), device.getId(), message); - - // We check for client presence after inserting the message to take a conservative view of notifications. If the - // client wasn't present at the time of insertion but is now, they'll retrieve the message. If they were present - // but disconnected before the message was delivered, we should send a notification. - clientPresent = clientPresenceManager.isPresent(account.getUuid(), device.getId()); - - if (!clientPresent) { - try { - pushNotificationManager.sendNewMessageNotification(account, device.getId(), message.getUrgent()); - - final boolean useVoip = StringUtils.isNotBlank(device.getVoipApnId()); - RedisOperation.unchecked(() -> pushLatencyManager.recordPushSent(account.getUuid(), device.getId(), useVoip)); - } catch (final NotPushRegisteredException e) { - if (!device.getFetchesMessages()) { - throw e; - } - } - } - } - - Metrics.counter(SEND_COUNTER_NAME, - CHANNEL_TAG_NAME, channel, - EPHEMERAL_TAG_NAME, String.valueOf(online), - CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent), - URGENT_TAG_NAME, String.valueOf(message.getUrgent()), - STORY_TAG_NAME, String.valueOf(message.getStory()), - SEALED_SENDER_TAG_NAME, String.valueOf(!message.hasSourceUuid()), - HAS_SPAM_REPORTING_TOKEN_TAG_NAME, String.valueOf(message.getReportSpamToken() != null && !message.getReportSpamToken().isEmpty())) - .increment(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java deleted file mode 100644 index c5dc14281..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -public class NotPushRegisteredException extends Exception { - public NotPushRegisteredException() { - super(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/ProvisioningManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/ProvisioningManager.java deleted file mode 100644 index aa114c711..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/ProvisioningManager.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import com.google.protobuf.ByteString; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import org.whispersystems.textsecuregcm.storage.PubSubManager; -import org.whispersystems.textsecuregcm.storage.PubSubProtos; -import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress; - -import static com.codahale.metrics.MetricRegistry.name; - -public class ProvisioningManager { - private final PubSubManager pubSubManager; - - private final Counter provisioningMessageOnlineCounter = Metrics.counter(name(getClass(), "sendProvisioningMessage"), "online", "true"); - private final Counter provisioningMessageOfflineCounter = Metrics.counter(name(getClass(), "sendProvisioningMessage"), "online", "false"); - - public ProvisioningManager(final PubSubManager pubSubManager) { - this.pubSubManager = pubSubManager; - } - - public boolean sendProvisioningMessage(ProvisioningAddress address, byte[] body) { - PubSubProtos.PubSubMessage pubSubMessage = PubSubProtos.PubSubMessage.newBuilder() - .setType(PubSubProtos.PubSubMessage.Type.DELIVER) - .setContent(ByteString.copyFrom(body)) - .build(); - - if (pubSubManager.publish(address, pubSubMessage)) { - provisioningMessageOnlineCounter.increment(); - return true; - } else { - provisioningMessageOfflineCounter.increment(); - return false; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushLatencyManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushLatencyManager.java deleted file mode 100644 index 6b7809ce4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushLatencyManager.java +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import com.codahale.metrics.MetricRegistry; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.google.common.annotations.VisibleForTesting; -import com.vdurmont.semver4j.Semver; -import io.lettuce.core.SetArgs; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import javax.annotation.Nullable; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; - -/** - * Measures and records the latency between sending a push notification to a device and that device draining its queue - * of messages. - *

- * When the server sends a push notification to a device, the push latency manager creates a Redis key/value pair - * mapping the current timestamp to the given device if such a mapping doesn't already exist. When a client connects and - * clears its message queue, the push latency manager gets and clears the time of the initial push notification to that - * device and records the time elapsed since the push notification timestamp as a latency observation. - */ -public class PushLatencyManager { - - private final FaultTolerantRedisCluster redisCluster; - private final DynamicConfigurationManager dynamicConfigurationManager; - - private final Clock clock; - - private static final String TIMER_NAME = MetricRegistry.name(PushLatencyManager.class, "latency"); - private static final int TTL = (int) Duration.ofDays(1).toSeconds(); - - private static final Logger log = LoggerFactory.getLogger(PushLatencyManager.class); - - @VisibleForTesting - enum PushType { - STANDARD, - VOIP - } - - @VisibleForTesting - static class PushRecord { - private final Instant timestamp; - - @Nullable - private final PushType pushType; - - @JsonCreator - PushRecord(@JsonProperty("timestamp") final Instant timestamp, - @JsonProperty("pushType") @Nullable final PushType pushType) { - - this.timestamp = timestamp; - this.pushType = pushType; - } - - public Instant getTimestamp() { - return timestamp; - } - - @Nullable - public PushType getPushType() { - return pushType; - } - } - - public PushLatencyManager(final FaultTolerantRedisCluster redisCluster, - final DynamicConfigurationManager dynamicConfigurationManager) { - - this(redisCluster, dynamicConfigurationManager, Clock.systemUTC()); - } - - @VisibleForTesting - PushLatencyManager(final FaultTolerantRedisCluster redisCluster, - final DynamicConfigurationManager dynamicConfigurationManager, - final Clock clock) { - - this.redisCluster = redisCluster; - this.dynamicConfigurationManager = dynamicConfigurationManager; - this.clock = clock; - } - - void recordPushSent(final UUID accountUuid, final long deviceId, final boolean isVoip) { - try { - final String recordJson = SystemMapper.getMapper().writeValueAsString( - new PushRecord(Instant.now(clock), isVoip ? PushType.VOIP : PushType.STANDARD)); - - redisCluster.useCluster(connection -> - connection.async().set(getFirstUnacknowledgedPushKey(accountUuid, deviceId), - recordJson, - SetArgs.Builder.nx().ex(TTL))); - } catch (final JsonProcessingException e) { - // This should never happen - log.error("Failed to write push latency record JSON", e); - } - } - - void recordQueueRead(final UUID accountUuid, final long deviceId, final String userAgentString) { - takePushRecord(accountUuid, deviceId).thenAccept(pushRecord -> { - if (pushRecord != null) { - final Duration latency = Duration.between(pushRecord.getTimestamp(), Instant.now()); - - final List tags = new ArrayList<>(2); - - tags.add(UserAgentTagUtil.getPlatformTag(userAgentString)); - - if (pushRecord.getPushType() != null) { - tags.add(Tag.of("pushType", pushRecord.getPushType().name().toLowerCase())); - } - - try { - final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString); - - final Set instrumentedVersions = - dynamicConfigurationManager.getConfiguration().getPushLatencyConfiguration().getInstrumentedVersions() - .getOrDefault(userAgent.getPlatform(), Collections.emptySet()); - - if (instrumentedVersions.contains(userAgent.getVersion())) { - tags.add(Tag.of("clientVersion", userAgent.getVersion().toString())); - } - } catch (UnrecognizedUserAgentException ignored) { - } - - Metrics.timer(TIMER_NAME, tags).record(latency); - } - }); - } - - @VisibleForTesting - CompletableFuture takePushRecord(final UUID accountUuid, final long deviceId) { - final String key = getFirstUnacknowledgedPushKey(accountUuid, deviceId); - - return redisCluster.withCluster(connection -> { - final CompletableFuture getFuture = connection.async().get(key).toCompletableFuture() - .thenApply(recordJson -> { - if (StringUtils.isNotEmpty(recordJson)) { - try { - return SystemMapper.getMapper().readValue(recordJson, PushRecord.class); - } catch (JsonProcessingException e) { - return null; - } - } else { - return null; - } - }); - - getFuture.whenComplete((record, cause) -> { - if (cause == null) { - connection.async().del(key); - } - }); - - return getFuture; - }); - } - - private static String getFirstUnacknowledgedPushKey(final UUID accountUuid, final long deviceId) { - return "push_latency::v2::" + accountUuid.toString() + "::" + deviceId; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java deleted file mode 100644 index 0b2297ffa..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import javax.annotation.Nullable; - -public record PushNotification(String deviceToken, - TokenType tokenType, - NotificationType notificationType, - @Nullable String data, - @Nullable Account destination, - @Nullable Device destinationDevice, - boolean urgent) { - - public enum NotificationType { - NOTIFICATION, CHALLENGE, RATE_LIMIT_CHALLENGE - } - - public enum TokenType { - FCM, - APN, - APN_VOIP, - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java deleted file mode 100644 index 89341babb..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.redis.RedisOperation; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.Util; - -public class PushNotificationManager { - - private final AccountsManager accountsManager; - private final APNSender apnSender; - private final FcmSender fcmSender; - private final ApnPushNotificationScheduler apnPushNotificationScheduler; - private final PushLatencyManager pushLatencyManager; - private final DynamicConfigurationManager dynamicConfigurationManager; - - private static final String SENT_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "sentPushNotification"); - private static final String FAILED_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, "failedPushNotification"); - - private final Logger logger = LoggerFactory.getLogger(PushNotificationManager.class); - - public PushNotificationManager(final AccountsManager accountsManager, - final APNSender apnSender, - final FcmSender fcmSender, - final ApnPushNotificationScheduler apnPushNotificationScheduler, - final PushLatencyManager pushLatencyManager, - final DynamicConfigurationManager dynamicConfigurationManager) { - - this.accountsManager = accountsManager; - this.apnSender = apnSender; - this.fcmSender = fcmSender; - this.apnPushNotificationScheduler = apnPushNotificationScheduler; - this.pushLatencyManager = pushLatencyManager; - this.dynamicConfigurationManager = dynamicConfigurationManager; - } - - public void sendNewMessageNotification(final Account destination, final long destinationDeviceId, final boolean urgent) throws NotPushRegisteredException { - final Device device = destination.getDevice(destinationDeviceId).orElseThrow(NotPushRegisteredException::new); - final Pair tokenAndType = getToken(device); - - final boolean effectiveUrgent = - dynamicConfigurationManager.getConfiguration().getPushNotificationConfiguration().isLowUrgencyEnabled() ? - urgent : true; - - sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), - PushNotification.NotificationType.NOTIFICATION, null, destination, device, effectiveUrgent)); - } - - public void sendRegistrationChallengeNotification(final String deviceToken, final PushNotification.TokenType tokenType, final String challengeToken) { - sendNotification(new PushNotification(deviceToken, tokenType, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true)); - } - - public void sendRateLimitChallengeNotification(final Account destination, final String challengeToken) - throws NotPushRegisteredException { - - final Device device = destination.getDevice(Device.MASTER_ID).orElseThrow(NotPushRegisteredException::new); - final Pair tokenAndType = getToken(device); - - sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(), - PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, destination, device, true)); - } - - public void handleMessagesRetrieved(final Account account, final Device device, final String userAgent) { - RedisOperation.unchecked(() -> pushLatencyManager.recordQueueRead(account.getUuid(), device.getId(), userAgent)); - RedisOperation.unchecked(() -> apnPushNotificationScheduler.cancelScheduledNotifications(account, device)); - } - - @VisibleForTesting - Pair getToken(final Device device) throws NotPushRegisteredException { - final Pair tokenAndType; - - if (StringUtils.isNotBlank(device.getGcmId())) { - tokenAndType = new Pair<>(device.getGcmId(), PushNotification.TokenType.FCM); - } else if (StringUtils.isNotBlank(device.getVoipApnId())) { - tokenAndType = new Pair<>(device.getVoipApnId(), PushNotification.TokenType.APN_VOIP); - } else if (StringUtils.isNotBlank(device.getApnId())) { - tokenAndType = new Pair<>(device.getApnId(), PushNotification.TokenType.APN); - } else { - throw new NotPushRegisteredException(); - } - - return tokenAndType; - } - - @VisibleForTesting - void sendNotification(final PushNotification pushNotification) { - if (pushNotification.tokenType() == PushNotification.TokenType.APN && !pushNotification.urgent()) { - // APNs imposes a per-device limit on background push notifications; schedule a notification for some time in the - // future (possibly even now!) rather than sending a notification directly - apnPushNotificationScheduler.scheduleBackgroundNotification(pushNotification.destination(), - pushNotification.destinationDevice()); - } else { - final PushNotificationSender sender = switch (pushNotification.tokenType()) { - case FCM -> fcmSender; - case APN, APN_VOIP -> apnSender; - }; - - sender.sendNotification(pushNotification).whenComplete((result, throwable) -> { - if (throwable == null) { - Tags tags = Tags.of("tokenType", pushNotification.tokenType().name(), - "notificationType", pushNotification.notificationType().name(), - "urgent", String.valueOf(pushNotification.urgent()), - "accepted", String.valueOf(result.accepted()), - "unregistered", String.valueOf(result.unregistered())); - - if (StringUtils.isNotBlank(result.errorCode())) { - tags = tags.and("errorCode", result.errorCode()); - } - - Metrics.counter(SENT_NOTIFICATION_COUNTER_NAME, tags).increment(); - - if (result.unregistered() && pushNotification.destination() != null - && pushNotification.destinationDevice() != null) { - handleDeviceUnregistered(pushNotification.destination(), pushNotification.destinationDevice()); - } - - if (result.accepted() && - pushNotification.tokenType() == PushNotification.TokenType.APN_VOIP && - pushNotification.notificationType() == PushNotification.NotificationType.NOTIFICATION && - pushNotification.destination() != null && - pushNotification.destinationDevice() != null) { - - RedisOperation.unchecked( - () -> apnPushNotificationScheduler.scheduleRecurringVoipNotification(pushNotification.destination(), - pushNotification.destinationDevice())); - } - } else { - logger.debug("Failed to deliver {} push notification to {} ({})", - pushNotification.notificationType(), pushNotification.deviceToken(), pushNotification.tokenType(), - throwable); - - Metrics.counter(FAILED_NOTIFICATION_COUNTER_NAME, "cause", throwable.getClass().getSimpleName()).increment(); - } - }); - } - } - - private void handleDeviceUnregistered(final Account account, final Device device) { - if (StringUtils.isNotBlank(device.getGcmId())) { - if (device.getUninstalledFeedbackTimestamp() == 0) { - accountsManager.updateDevice(account, device.getId(), d -> - d.setUninstalledFeedbackTimestamp(Util.todayInMillis())); - } - } else { - RedisOperation.unchecked(() -> apnPushNotificationScheduler.cancelScheduledNotifications(account, device)); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java deleted file mode 100644 index 2631efda2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import java.util.concurrent.CompletableFuture; - -public interface PushNotificationSender { - - CompletableFuture sendNotification(PushNotification notification); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/ReceiptSender.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/ReceiptSender.java deleted file mode 100644 index e34cf9498..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/ReceiptSender.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import com.codahale.metrics.InstrumentedExecutorService; -import com.codahale.metrics.SharedMetricRegistries; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Constants; - -public class ReceiptSender { - - private final MessageSender messageSender; - private final AccountsManager accountManager; - private final ExecutorService executor; - - private static final Logger logger = LoggerFactory.getLogger(ReceiptSender.class); - - public ReceiptSender(final AccountsManager accountManager, final MessageSender messageSender, - final ExecutorService executor) { - this.accountManager = accountManager; - this.messageSender = messageSender; - this.executor = new InstrumentedExecutorService(executor, - SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), - MetricsUtil.name(ReceiptSender.class, "executor")); - } - - public void sendReceipt(UUID sourceUuid, long sourceDeviceId, UUID destinationUuid, long messageId) { - if (sourceUuid.equals(destinationUuid)) { - return; - } - - executor.submit(() -> { - try { - accountManager.getByAccountIdentifier(destinationUuid).ifPresentOrElse( - destinationAccount -> { - final Envelope.Builder message = Envelope.newBuilder() - .setServerTimestamp(System.currentTimeMillis()) - .setSourceUuid(sourceUuid.toString()) - .setSourceDevice((int) sourceDeviceId) - .setDestinationUuid(destinationUuid.toString()) - .setTimestamp(messageId) - .setType(Envelope.Type.SERVER_DELIVERY_RECEIPT) - .setUrgent(false); - - for (final Device destinationDevice : destinationAccount.getDevices()) { - try { - messageSender.sendMessage(destinationAccount, destinationDevice, message.build(), false); - } catch (final NotPushRegisteredException e) { - logger.debug("User no longer push registered for delivery receipt: {}", e.getMessage()); - } catch (final Exception e) { - logger.warn("Could not send delivery receipt", e); - } - } - }, - () -> logger.info("No longer registered: {}", destinationUuid) - ); - - } catch (final Exception e) { - // this exception is most likely a Dynamo timeout or a Redis timeout/circuit breaker - logger.warn("Could not send delivery receipt", e); - } - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java deleted file mode 100644 index fc7abdd85..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import javax.annotation.Nullable; - -public record SendPushNotificationResult(boolean accepted, @Nullable String errorCode, boolean unregistered) { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/push/TransientPushFailureException.java b/service/src/main/java/org/whispersystems/textsecuregcm/push/TransientPushFailureException.java deleted file mode 100644 index cedf9c837..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/push/TransientPushFailureException.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -public class TransientPushFailureException extends Exception { - public TransientPushFailureException(String s) { - super(s); - } - - public TransientPushFailureException(Exception e) { - super(e); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScript.java b/service/src/main/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScript.java deleted file mode 100644 index 3915f53bd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScript.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import com.google.common.annotations.VisibleForTesting; -import io.lettuce.core.RedisException; -import io.lettuce.core.RedisNoScriptException; -import io.lettuce.core.ScriptOutputType; -import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public class ClusterLuaScript { - - private final FaultTolerantRedisCluster redisCluster; - private final ScriptOutputType scriptOutputType; - private final String script; - private final String sha; - - private static final String[] STRING_ARRAY = new String[0]; - private static final byte[][] BYTE_ARRAY_ARRAY = new byte[0][]; - - private static final Logger log = LoggerFactory.getLogger(ClusterLuaScript.class); - - public static ClusterLuaScript fromResource(final FaultTolerantRedisCluster redisCluster, - final String resource, - final ScriptOutputType scriptOutputType) throws IOException { - - try (final InputStream inputStream = ClusterLuaScript.class.getClassLoader().getResourceAsStream(resource)) { - if (inputStream == null) { - throw new IllegalArgumentException("Script not found: " + resource); - } - - return new ClusterLuaScript(redisCluster, - new String(inputStream.readAllBytes(), StandardCharsets.UTF_8), - scriptOutputType); - } - } - - @VisibleForTesting - ClusterLuaScript(final FaultTolerantRedisCluster redisCluster, - final String script, - final ScriptOutputType scriptOutputType) { - - this.redisCluster = redisCluster; - this.scriptOutputType = scriptOutputType; - this.script = script; - - try { - this.sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-1").digest(script.getBytes(StandardCharsets.UTF_8))); - } catch (final NoSuchAlgorithmException e) { - // All Java implementations are required to support SHA-1, so this should never happen - throw new AssertionError(e); - } - } - - @VisibleForTesting - String getSha() { - return sha; - } - - public Object execute(final List keys, final List args) { - return redisCluster.withCluster(connection -> - execute(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY))); - } - - public CompletableFuture executeAsync(final List keys, final List args) { - return redisCluster.withCluster(connection -> - executeAsync(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY))); - } - - public Flux executeReactive(final List keys, final List args) { - return redisCluster.withCluster(connection -> - executeReactive(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY))); - } - - public Object executeBinary(final List keys, final List args) { - return redisCluster.withBinaryCluster(connection -> - execute(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY))); - } - - public CompletableFuture executeBinaryAsync(final List keys, final List args) { - return redisCluster.withBinaryCluster(connection -> - executeAsync(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY))); - } - - public Flux executeBinaryReactive(final List keys, final List args) { - return redisCluster.withBinaryCluster(connection -> - executeReactive(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY))); - } - - private Object execute(final StatefulRedisClusterConnection connection, final T[] keys, final T[] args) { - try { - try { - return connection.sync().evalsha(sha, scriptOutputType, keys, args); - } catch (final RedisNoScriptException e) { - return connection.sync().eval(script, scriptOutputType, keys, args); - } - } catch (final Exception e) { - log.warn("Failed to execute script", e); - throw e; - } - } - - private CompletableFuture executeAsync(final StatefulRedisClusterConnection connection, - final T[] keys, final T[] args) { - - return connection.async().evalsha(sha, scriptOutputType, keys, args) - .exceptionallyCompose(throwable -> { - if (throwable instanceof RedisNoScriptException) { - return connection.async().eval(script, scriptOutputType, keys, args); - } - - log.warn("Failed to execute script", throwable); - throw new RedisException(throwable); - }).toCompletableFuture(); - } - - private Flux executeReactive(final StatefulRedisClusterConnection connection, - final T[] keys, final T[] args) { - - return connection.reactive().evalsha(sha, scriptOutputType, keys, args) - .onErrorResume(e -> { - if (e instanceof RedisNoScriptException) { - return connection.reactive().eval(script, scriptOutputType, keys, args); - } - - log.warn("Failed to execute script", e); - return Mono.error(e); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/redis/ConnectionEventLogger.java b/service/src/main/java/org/whispersystems/textsecuregcm/redis/ConnectionEventLogger.java deleted file mode 100644 index aa059b25b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/redis/ConnectionEventLogger.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import io.lettuce.core.cluster.event.ClusterTopologyChangedEvent; -import io.lettuce.core.event.connection.ConnectionEvent; -import io.lettuce.core.resource.ClientResources; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ConnectionEventLogger { - - private static final Logger logger = LoggerFactory.getLogger(ConnectionEventLogger.class); - - public static void logConnectionEvents(final ClientResources clientResources) { - clientResources.eventBus().get().subscribe(event -> { - if (event instanceof ConnectionEvent) { - logger.debug("Connection event: {}", event); - } else if (event instanceof ClusterTopologyChangedEvent) { - logger.info("Cluster topology changed: {}", event); - } - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubConnection.java b/service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubConnection.java deleted file mode 100644 index 13e599ca4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubConnection.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import io.github.resilience4j.circuitbreaker.CircuitBreaker; -import io.github.resilience4j.retry.Retry; -import io.lettuce.core.RedisException; -import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection; -import java.util.function.Consumer; -import java.util.function.Function; -import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil; -import org.whispersystems.textsecuregcm.util.Constants; - -public class FaultTolerantPubSubConnection { - - private final StatefulRedisClusterPubSubConnection pubSubConnection; - - private final CircuitBreaker circuitBreaker; - private final Retry retry; - - private final Timer executeTimer; - - public FaultTolerantPubSubConnection(final String name, final StatefulRedisClusterPubSubConnection pubSubConnection, final CircuitBreaker circuitBreaker, final Retry retry) { - this.pubSubConnection = pubSubConnection; - this.circuitBreaker = circuitBreaker; - this.retry = retry; - - this.pubSubConnection.setNodeMessagePropagation(true); - - final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - this.executeTimer = metricRegistry.timer(name(getClass(), name + "-pubsub", "execute")); - - CircuitBreakerUtil.registerMetrics(metricRegistry, circuitBreaker, FaultTolerantPubSubConnection.class); - } - - public void usePubSubConnection(final Consumer> consumer) { - try { - circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> { - try (final Timer.Context ignored = executeTimer.time()) { - consumer.accept(pubSubConnection); - } - })); - } catch (final Throwable t) { - if (t instanceof RedisException) { - throw (RedisException) t; - } else { - throw new RedisException(t); - } - } - } - - public T withPubSubConnection(final Function, T> function) { - try { - return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> { - try (final Timer.Context ignored = executeTimer.time()) { - return function.apply(pubSubConnection); - } - })); - } catch (final Throwable t) { - if (t instanceof RedisException) { - throw (RedisException) t; - } else { - throw new RedisException(t); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisCluster.java b/service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisCluster.java deleted file mode 100644 index fd60d5fc6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisCluster.java +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import com.codahale.metrics.SharedMetricRegistries; -import com.google.common.annotations.VisibleForTesting; -import io.github.resilience4j.circuitbreaker.CircuitBreaker; -import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator; -import io.github.resilience4j.reactor.retry.RetryOperator; -import io.github.resilience4j.retry.Retry; -import io.lettuce.core.ClientOptions.DisconnectedBehavior; -import io.lettuce.core.RedisCommandTimeoutException; -import io.lettuce.core.RedisException; -import io.lettuce.core.cluster.ClusterClientOptions; -import io.lettuce.core.cluster.ClusterTopologyRefreshOptions; -import io.lettuce.core.cluster.RedisClusterClient; -import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; -import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection; -import io.lettuce.core.codec.ByteArrayCodec; -import io.lettuce.core.resource.ClientResources; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; -import org.reactivestreams.Publisher; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration; -import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; -import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil; -import org.whispersystems.textsecuregcm.util.Constants; -import reactor.core.publisher.Flux; - -/** - * A fault-tolerant access manager for a Redis cluster. A fault-tolerant Redis cluster provides managed, - * circuit-breaker-protected access to connections. - */ -public class FaultTolerantRedisCluster { - - private final String name; - - private final RedisClusterClient clusterClient; - - private final StatefulRedisClusterConnection stringConnection; - private final StatefulRedisClusterConnection binaryConnection; - - private final List> pubSubConnections = new ArrayList<>(); - - private final CircuitBreaker circuitBreaker; - private final Retry retry; - - public FaultTolerantRedisCluster(final String name, final RedisClusterConfiguration clusterConfiguration, final ClientResources clientResources) { - this(name, - RedisClusterClient.create(clientResources, clusterConfiguration.getConfigurationUri()), - clusterConfiguration.getTimeout(), - clusterConfiguration.getCircuitBreakerConfiguration(), - clusterConfiguration.getRetryConfiguration()); - } - - @VisibleForTesting - FaultTolerantRedisCluster(final String name, final RedisClusterClient clusterClient, final Duration commandTimeout, final CircuitBreakerConfiguration circuitBreakerConfiguration, final RetryConfiguration retryConfiguration) { - this.name = name; - - this.clusterClient = clusterClient; - this.clusterClient.setDefaultTimeout(commandTimeout); - this.clusterClient.setOptions(ClusterClientOptions.builder() - .disconnectedBehavior(DisconnectedBehavior.REJECT_COMMANDS) - .validateClusterNodeMembership(false) - .topologyRefreshOptions(ClusterTopologyRefreshOptions.builder() - .enableAllAdaptiveRefreshTriggers() - .build()) - .publishOnScheduler(true) - .build()); - - this.stringConnection = clusterClient.connect(); - this.binaryConnection = clusterClient.connect(ByteArrayCodec.INSTANCE); - - this.circuitBreaker = CircuitBreaker.of(name + "-breaker", circuitBreakerConfiguration.toCircuitBreakerConfig()); - this.retry = Retry.of(name + "-retry", retryConfiguration.toRetryConfigBuilder() - .retryOnException(exception -> exception instanceof RedisCommandTimeoutException).build()); - - CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), circuitBreaker, - FaultTolerantRedisCluster.class); - CircuitBreakerUtil.registerMetrics(SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME), retry, - FaultTolerantRedisCluster.class); - } - - void shutdown() { - stringConnection.close(); - binaryConnection.close(); - - for (final StatefulRedisClusterPubSubConnection pubSubConnection : pubSubConnections) { - pubSubConnection.close(); - } - - clusterClient.shutdown(); - } - - public String getName() { - return name; - } - - public void useCluster(final Consumer> consumer) { - useConnection(stringConnection, consumer); - } - - public T withCluster(final Function, T> function) { - return withConnection(stringConnection, function); - } - - public void useBinaryCluster(final Consumer> consumer) { - useConnection(binaryConnection, consumer); - } - - public T withBinaryCluster(final Function, T> function) { - return withConnection(binaryConnection, function); - } - - public Publisher withBinaryClusterReactive( - final Function, Publisher> function) { - return withConnectionReactive(binaryConnection, function); - } - - private void useConnection(final StatefulRedisClusterConnection connection, - final Consumer> consumer) { - try { - circuitBreaker.executeCheckedRunnable(() -> retry.executeRunnable(() -> consumer.accept(connection))); - } catch (final Throwable t) { - if (t instanceof RedisException) { - throw (RedisException) t; - } else { - throw new RedisException(t); - } - } - } - - private T withConnection(final StatefulRedisClusterConnection connection, - final Function, T> function) { - try { - return circuitBreaker.executeCheckedSupplier(() -> retry.executeCallable(() -> function.apply(connection))); - } catch (final Throwable t) { - if (t instanceof RedisException) { - throw (RedisException) t; - } else { - throw new RedisException(t); - } - } - } - - private Publisher withConnectionReactive(final StatefulRedisClusterConnection connection, - final Function, Publisher> function) { - - return Flux.from(function.apply(connection)) - .transformDeferred(RetryOperator.of(retry)) - .transformDeferred(CircuitBreakerOperator.of(circuitBreaker)); - } - - public FaultTolerantPubSubConnection createPubSubConnection() { - final StatefulRedisClusterPubSubConnection pubSubConnection = clusterClient.connectPubSub(); - pubSubConnections.add(pubSubConnection); - - return new FaultTolerantPubSubConnection<>(name, pubSubConnection, circuitBreaker, retry); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/redis/RedisOperation.java b/service/src/main/java/org/whispersystems/textsecuregcm/redis/RedisOperation.java deleted file mode 100644 index f4f548db9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/redis/RedisOperation.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import io.lettuce.core.RedisException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class RedisOperation { - - private static final Logger logger = LoggerFactory.getLogger(RedisOperation.class); - - /** - * Executes the given task and logs and discards any {@link RedisException} that may be thrown. This method should be - * used for best-effort tasks like gathering metrics. - * - * @param runnable the Redis-related task to be executed - */ - public static void unchecked(final Runnable runnable) { - try { - runnable.run(); - } catch (RedisException e) { - logger.warn("Redis failure", e); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/redis/ReplicatedJedisPool.java b/service/src/main/java/org/whispersystems/textsecuregcm/redis/ReplicatedJedisPool.java deleted file mode 100644 index 8c76d9fcf..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/redis/ReplicatedJedisPool.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.util.CircuitBreakerUtil; -import org.whispersystems.textsecuregcm.util.Constants; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; - -import io.github.resilience4j.circuitbreaker.CircuitBreaker; -import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; -import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPool; -import redis.clients.jedis.exceptions.JedisException; - -public class ReplicatedJedisPool { - - private final Logger logger = LoggerFactory.getLogger(ReplicatedJedisPool.class); - private final AtomicInteger replicaIndex = new AtomicInteger(0); - - private final Supplier master; - private final ArrayList> replicas; - - public ReplicatedJedisPool(String name, - JedisPool master, - List replicas, - CircuitBreakerConfiguration circuitBreakerConfiguration) - { - if (replicas.size() < 1) throw new IllegalArgumentException("There must be at least one replica"); - - MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - CircuitBreakerConfig config = circuitBreakerConfiguration.toCircuitBreakerConfig(); - CircuitBreaker masterBreaker = CircuitBreaker.of(String.format("%s-master", name), config); - - CircuitBreakerUtil.registerMetrics(metricRegistry, masterBreaker, ReplicatedJedisPool.class); - - this.master = CircuitBreaker.decorateSupplier(masterBreaker, master::getResource); - this.replicas = new ArrayList<>(replicas.size()); - - for (int i=0;i API_KEY_METADATA_KEY = - Metadata.Key.of("x-signal-api-key", Metadata.ASCII_STRING_MARSHALLER); - - ApiKeyCallCredentials(final String apiKey) { - this.apiKey = apiKey; - } - - @Override - public void applyRequestMetadata(final RequestInfo requestInfo, - final Executor appExecutor, - final MetadataApplier applier) { - - final Metadata metadata = new Metadata(); - metadata.put(API_KEY_METADATA_KEY, apiKey); - - applier.apply(metadata); - } - - @Override - public void thisUsesUnstableApi() { - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/ClientType.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/ClientType.java deleted file mode 100644 index 7a5a9c546..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/registration/ClientType.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.registration; - -public enum ClientType { - IOS, - ANDROID_WITH_FCM, - ANDROID_WITHOUT_FCM, - UNKNOWN -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/MessageTransport.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/MessageTransport.java deleted file mode 100644 index f45f0f0e3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/registration/MessageTransport.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.registration; - -/** - * A message transport is a medium via which verification codes can be delivered to a destination phone. - */ -public enum MessageTransport { - SMS, - VOICE -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java deleted file mode 100644 index df6d30f2d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java +++ /dev/null @@ -1,229 +0,0 @@ -package org.whispersystems.textsecuregcm.registration; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import com.google.i18n.phonenumbers.Phonenumber; -import com.google.protobuf.ByteString; -import io.dropwizard.lifecycle.Managed; -import io.grpc.ChannelCredentials; -import io.grpc.Deadline; -import io.grpc.Grpc; -import io.grpc.ManagedChannel; -import io.grpc.TlsChannelCredentials; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; -import org.apache.commons.lang3.StringUtils; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.signal.registration.rpc.CheckVerificationCodeRequest; -import org.signal.registration.rpc.CreateRegistrationSessionRequest; -import org.signal.registration.rpc.GetRegistrationSessionMetadataRequest; -import org.signal.registration.rpc.RegistrationServiceGrpc; -import org.signal.registration.rpc.SendVerificationCodeRequest; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.entities.RegistrationSession; - -public class RegistrationServiceClient implements Managed { - - private final ManagedChannel channel; - private final RegistrationServiceGrpc.RegistrationServiceFutureStub stub; - private final Executor callbackExecutor; - - /** - * @param from an e164 in a {@code long} representation e.g. {@code 18005550123} - * @return the e164 in a {@code String} representation (e.g. {@code "+18005550123"}) - * @throws IllegalArgumentException if the number cannot be parsed to a string - */ - static String convertNumeralE164ToString(long from) { - - try { - final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance() - .parse("+" + from, null); - return PhoneNumberUtil.getInstance() - .format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164); - } catch (final NumberParseException e) { - throw new IllegalArgumentException("could not parse to phone number", e); - } - - } - - public RegistrationServiceClient(final String host, - final int port, - final String apiKey, - final String caCertificatePem, - final Executor callbackExecutor) throws IOException { - - try (final ByteArrayInputStream certificateInputStream = new ByteArrayInputStream(caCertificatePem.getBytes(StandardCharsets.UTF_8))) { - final ChannelCredentials tlsChannelCredentials = TlsChannelCredentials.newBuilder() - .trustManager(certificateInputStream) - .build(); - - this.channel = Grpc.newChannelBuilderForAddress(host, port, tlsChannelCredentials).build(); - } - - this.stub = RegistrationServiceGrpc.newFutureStub(channel) - .withCallCredentials(new ApiKeyCallCredentials(apiKey)); - - this.callbackExecutor = callbackExecutor; - } - - public CompletableFuture createRegistrationSession(final Phonenumber.PhoneNumber phoneNumber, final Duration timeout) { - final long e164 = Long.parseLong( - PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).substring(1)); - - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) - .createSession(CreateRegistrationSessionRequest.newBuilder() - .setE164(e164) - .build())) - .thenApply(response -> switch (response.getResponseCase()) { - case SESSION_METADATA -> response.getSessionMetadata().getSessionId().toByteArray(); - - case ERROR -> { - switch (response.getError().getErrorType()) { - case CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException( - new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()), - true)); - case CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER -> throw new IllegalArgumentException(); - default -> throw new RuntimeException( - "Unrecognized error type from registration service: " + response.getError().getErrorType()); - } - } - - case RESPONSE_NOT_SET -> throw new RuntimeException("No response from registration service"); - }); - } - - public CompletableFuture sendRegistrationCode(final byte[] sessionId, - final MessageTransport messageTransport, - final ClientType clientType, - @Nullable final String acceptLanguage, - final Duration timeout) { - - final SendVerificationCodeRequest.Builder requestBuilder = SendVerificationCodeRequest.newBuilder() - .setSessionId(ByteString.copyFrom(sessionId)) - .setTransport(getRpcMessageTransport(messageTransport)) - .setClientType(getRpcClientType(clientType)); - - if (StringUtils.isNotBlank(acceptLanguage)) { - requestBuilder.setAcceptLanguage(acceptLanguage); - } - - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) - .sendVerificationCode(requestBuilder.build())) - .thenApply(response -> { - if (response.hasError()) { - switch (response.getError().getErrorType()) { - case SEND_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException( - new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()), - true)); - - default -> throw new CompletionException(new RuntimeException("Failed to send verification code: " + response.getError().getErrorType())); - } - } else { - return response.getSessionId().toByteArray(); - } - }); - } - - public CompletableFuture checkVerificationCode(final byte[] sessionId, - final String verificationCode, - final Duration timeout) { - - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)) - .checkVerificationCode(CheckVerificationCodeRequest.newBuilder() - .setSessionId(ByteString.copyFrom(sessionId)) - .setVerificationCode(verificationCode) - .build())) - .thenApply(response -> { - if (response.hasError()) { - switch (response.getError().getErrorType()) { - case CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException( - new RateLimitExceededException(Duration.ofSeconds(response.getError().getRetryAfterSeconds()), - true)); - - default -> throw new CompletionException(new RuntimeException("Failed to check verification code: " + response.getError().getErrorType())); - } - } else { - return response.getVerified() || response.getSessionMetadata().getVerified(); - } - }); - } - - public CompletableFuture> getSession(final byte[] sessionId, - final Duration timeout) { - return toCompletableFuture(stub.withDeadline(toDeadline(timeout)).getSessionMetadata( - GetRegistrationSessionMetadataRequest.newBuilder() - .setSessionId(ByteString.copyFrom(sessionId)).build())) - .thenApply(response -> { - if (response.hasError()) { - switch (response.getError().getErrorType()) { - case GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_NOT_FOUND -> { - return Optional.empty(); - } - default -> throw new RuntimeException("Failed to get session: " + response.getError().getErrorType()); - } - } - - final String number = convertNumeralE164ToString(response.getSessionMetadata().getE164()); - return Optional.of(new RegistrationSession(number, response.getSessionMetadata().getVerified())); - }); - } - - private static Deadline toDeadline(final Duration timeout) { - return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS); - } - - private static org.signal.registration.rpc.ClientType getRpcClientType(final ClientType clientType) { - return switch (clientType) { - case IOS -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_IOS; - case ANDROID_WITH_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITH_FCM; - case ANDROID_WITHOUT_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITHOUT_FCM; - case UNKNOWN -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_UNSPECIFIED; - }; - } - - private static org.signal.registration.rpc.MessageTransport getRpcMessageTransport(final MessageTransport transport) { - return switch (transport) { - case SMS -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_SMS; - case VOICE -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_VOICE; - }; - } - - private CompletableFuture toCompletableFuture(final ListenableFuture listenableFuture) { - final CompletableFuture completableFuture = new CompletableFuture<>(); - - Futures.addCallback(listenableFuture, new FutureCallback() { - @Override - public void onSuccess(@Nullable final T result) { - completableFuture.complete(result); - } - - @Override - public void onFailure(final Throwable throwable) { - completableFuture.completeExceptionally(throwable); - } - }, callbackExecutor); - - return completableFuture; - } - - @Override - public void start() throws Exception { - } - - @Override - public void stop() throws Exception { - if (channel != null) { - channel.shutdown(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java b/service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java deleted file mode 100644 index 4067507d6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.s3; - -import com.amazonaws.util.Base16Lower; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.io.UnsupportedEncodingException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; - -public class PolicySigner { - - private final String awsAccessSecret; - private final String region; - - public PolicySigner(String awsAccessSecret, String region) { - this.awsAccessSecret = awsAccessSecret; - this.region = region; - } - - public String getSignature(ZonedDateTime now, String policy) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - - mac.init(new SecretKeySpec(("AWS4" + awsAccessSecret).getBytes("UTF-8"), "HmacSHA256")); - byte[] dateKey = mac.doFinal(now.format(DateTimeFormatter.ofPattern("yyyyMMdd")).getBytes("UTF-8")); - - mac.init(new SecretKeySpec(dateKey, "HmacSHA256")); - byte[] dateRegionKey = mac.doFinal(region.getBytes("UTF-8")); - - mac.init(new SecretKeySpec(dateRegionKey, "HmacSHA256")); - byte[] dateRegionServiceKey = mac.doFinal("s3".getBytes("UTF-8")); - - mac.init(new SecretKeySpec(dateRegionServiceKey, "HmacSHA256")); - byte[] signingKey = mac.doFinal("aws4_request".getBytes("UTF-8")); - - mac.init(new SecretKeySpec(signingKey, "HmacSHA256")); - - return Base16Lower.encodeAsString(mac.doFinal(policy.getBytes("UTF-8"))); - } catch (NoSuchAlgorithmException | InvalidKeyException | UnsupportedEncodingException e) { - throw new AssertionError(e); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java deleted file mode 100644 index 744891e8e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.s3; - -import java.nio.charset.StandardCharsets; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Base64; -import org.whispersystems.textsecuregcm.util.Pair; - -public class PostPolicyGenerator { - - public static final DateTimeFormatter AWS_DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmssX"); - private static final DateTimeFormatter CREDENTIAL_DATE = DateTimeFormatter.ofPattern("yyyyMMdd" ); - - private final String region; - private final String bucket; - private final String awsAccessId; - - public PostPolicyGenerator(String region, String bucket, String awsAccessId) { - this.region = region; - this.bucket = bucket; - this.awsAccessId = awsAccessId; - } - - public Pair createFor(ZonedDateTime now, String object, int maxSizeInBytes) { - String expiration = now.plusMinutes(30).format(DateTimeFormatter.ISO_INSTANT); - String credentialDate = now.format(CREDENTIAL_DATE); - String requestDate = now.format(AWS_DATE_TIME); - String credential = String.format("%s/%s/%s/s3/aws4_request", awsAccessId, credentialDate, region); - - String policy = String.format("{ \"expiration\": \"%s\",\n" + - " \"conditions\": [\n" + - " {\"bucket\": \"%s\"},\n" + - " {\"key\": \"%s\"},\n" + - " {\"acl\": \"private\"},\n" + - " [\"starts-with\", \"$Content-Type\", \"\"],\n" + - " [\"content-length-range\", 1, " + maxSizeInBytes + "],\n" + - "\n" + - " {\"x-amz-credential\": \"%s\"},\n" + - " {\"x-amz-algorithm\": \"AWS4-HMAC-SHA256\"},\n" + - " {\"x-amz-date\": \"%s\" }\n" + - " ]\n" + - "}", expiration, bucket, object, credential, requestDate); - - return new Pair<>(credential, Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8))); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClient.java deleted file mode 100644 index 52a4628de..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClient.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.securebackup; - -import static org.whispersystems.textsecuregcm.util.HeaderUtils.basicAuthHeader; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.security.cert.CertificateException; -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; -import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; -import org.whispersystems.textsecuregcm.util.HttpUtils; - -/** - * A client for sending requests to Signal's secure value recovery service on behalf of authenticated users. - */ -public class SecureBackupClient { - - private final ExternalServiceCredentialsGenerator secureBackupCredentialsGenerator; - private final URI deleteUri; - private final FaultTolerantHttpClient httpClient; - - @VisibleForTesting - static final String DELETE_PATH = "/v1/backup"; - - public SecureBackupClient(final ExternalServiceCredentialsGenerator secureBackupCredentialsGenerator, final Executor executor, final SecureBackupServiceConfiguration configuration) throws CertificateException { - this.secureBackupCredentialsGenerator = secureBackupCredentialsGenerator; - this.deleteUri = URI.create(configuration.getUri()).resolve(DELETE_PATH); - this.httpClient = FaultTolerantHttpClient.newBuilder() - .withCircuitBreaker(configuration.getCircuitBreakerConfiguration()) - .withRetry(configuration.getRetryConfiguration()) - .withVersion(HttpClient.Version.HTTP_1_1) - .withConnectTimeout(Duration.ofSeconds(10)) - .withRedirect(HttpClient.Redirect.NEVER) - .withExecutor(executor) - .withName("secure-backup") - .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_2) - .withTrustedServerCertificates(configuration.getBackupCaCertificates().toArray(new String[0])) - .build(); - } - - public CompletableFuture deleteBackups(final UUID accountUuid) { - final ExternalServiceCredentials credentials = secureBackupCredentialsGenerator.generateForUuid(accountUuid); - - final HttpRequest request = HttpRequest.newBuilder() - .uri(deleteUri) - .DELETE() - .header(HttpHeaders.AUTHORIZATION, basicAuthHeader(credentials)) - .build(); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { - if (HttpUtils.isSuccessfulResponse(response.statusCode())) { - return null; - } - - throw new SecureBackupException("Failed to delete backup: " + response.statusCode()); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupException.java b/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupException.java deleted file mode 100644 index 001a8a448..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupException.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.securebackup; - -public class SecureBackupException extends RuntimeException { - - public SecureBackupException(final String message) { - super(message); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClient.java deleted file mode 100644 index 2918a5179..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClient.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.securestorage; - -import static org.whispersystems.textsecuregcm.util.HeaderUtils.basicAuthHeader; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.security.cert.CertificateException; -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; -import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; -import org.whispersystems.textsecuregcm.util.HttpUtils; - -/** - * A client for sending requests to Signal's secure storage service on behalf of authenticated users. - */ -public class SecureStorageClient { - - private final ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator; - private final URI deleteUri; - private final FaultTolerantHttpClient httpClient; - - @VisibleForTesting - static final String DELETE_PATH = "/v1/storage"; - - public SecureStorageClient(final ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator, final Executor executor, final SecureStorageServiceConfiguration configuration) throws CertificateException { - this.storageServiceCredentialsGenerator = storageServiceCredentialsGenerator; - this.deleteUri = URI.create(configuration.uri()).resolve(DELETE_PATH); - this.httpClient = FaultTolerantHttpClient.newBuilder() - .withCircuitBreaker(configuration.circuitBreaker()) - .withRetry(configuration.retry()) - .withVersion(HttpClient.Version.HTTP_1_1) - .withConnectTimeout(Duration.ofSeconds(10)) - .withRedirect(HttpClient.Redirect.NEVER) - .withExecutor(executor) - .withName("secure-storage") - .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3) - .withTrustedServerCertificates(configuration.storageCaCertificates().toArray(new String[0])) - .build(); - } - - public CompletableFuture deleteStoredData(final UUID accountUuid) { - final ExternalServiceCredentials credentials = storageServiceCredentialsGenerator.generateForUuid(accountUuid); - - final HttpRequest request = HttpRequest.newBuilder() - .uri(deleteUri) - .DELETE() - .header(HttpHeaders.AUTHORIZATION, basicAuthHeader(credentials)) - .build(); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> { - if (HttpUtils.isSuccessfulResponse(response.statusCode())) { - return null; - } - - throw new SecureStorageException("Failed to delete storage service data: " + response.statusCode()); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageException.java b/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageException.java deleted file mode 100644 index 31a06de0a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageException.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.securestorage; - -public class SecureStorageException extends RuntimeException { - - public SecureStorageException(final String message) { - super(message); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/FilterSpam.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/FilterSpam.java deleted file mode 100644 index 9a771acfd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/spam/FilterSpam.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.spam; - -import javax.ws.rs.NameBinding; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * A name-binding annotation that associates {@link SpamFilter}s with resource methods. - */ -@NameBinding -@Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE, ElementType.METHOD}) -public @interface FilterSpam { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/RateLimitChallengeListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/RateLimitChallengeListener.java deleted file mode 100644 index b15fa171d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/spam/RateLimitChallengeListener.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.spam; - - -import org.whispersystems.textsecuregcm.storage.Account; -import java.io.IOException; - -public interface RateLimitChallengeListener { - - void handleRateLimitChallengeAnswered(Account account); - - /** - * Configures this rate limit challenge listener. This method will be called before the service begins processing any - * challenges. - * - * @param environmentName the name of the environment in which this listener is running (e.g. "staging" or "production") - * @throws IOException if the listener could not read its configuration source for any reason - */ - void configure(String environmentName) throws IOException; -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/RateLimitChallengeType.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/RateLimitChallengeType.java deleted file mode 100644 index 2b7f298e5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/spam/RateLimitChallengeType.java +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.spam; - -public enum RateLimitChallengeType { - - PUSH_CHALLENGE, - RECAPTCHA -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/ReportSpamTokenProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/ReportSpamTokenProvider.java deleted file mode 100644 index 00e616434..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/spam/ReportSpamTokenProvider.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.whispersystems.textsecuregcm.spam; - -import javax.ws.rs.container.ContainerRequestContext; -import java.util.Optional; -import java.util.function.Function; - -/** - * Generates ReportSpamTokens to be used for spam reports. - */ -public interface ReportSpamTokenProvider { - - /** - * Generate a new ReportSpamToken - * - * @param context the message request context - * @return either a generated token or nothing - */ - Optional makeReportSpamToken(ContainerRequestContext context); - - /** - * Provider which generates nothing - * - * @return the provider - */ - static ReportSpamTokenProvider noop() { - return context -> Optional.empty(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamFilter.java deleted file mode 100644 index 2591f0ae3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamFilter.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.spam; - -import io.dropwizard.lifecycle.Managed; -import org.whispersystems.textsecuregcm.storage.ReportedMessageListener; -import javax.ws.rs.container.ContainerRequestFilter; -import java.io.IOException; -import java.util.List; - -/** - * A spam filter is a {@link ContainerRequestFilter} that filters requests to message-sending endpoints to - * detect and respond to patterns of spam. - *

- * Spam filters are managed components that are generally loaded dynamically via a - * {@link java.util.ServiceLoader}. Their {@link #configure(String)} method will be called prior to be adding to the - * server's pool of {@link Managed} objects. - *

- * Spam filters must be annotated with {@link FilterSpam}, a name binding annotation that - * restricts the endpoints to which the filter may apply. - */ -public interface SpamFilter extends ContainerRequestFilter, Managed { - - /** - * Configures this spam filter. This method will be called before the filter is added to the server's pool - * of managed objects and before the server processes any requests. - * - * @param environmentName the name of the environment in which this filter is running (e.g. "staging" or "production") - * @throws IOException if the filter could not read its configuration source for any reason - */ - void configure(String environmentName) throws IOException; - - /** - * Builds a spam report token provider. This will generate tokens used by the spam reporting system. - * - * @return the configured spam report token provider. - */ - ReportSpamTokenProvider getReportSpamTokenProvider(); - - /** - * Return any and all reported message listeners controlled by the spam filter. Listeners will be registered with the - * {@link org.whispersystems.textsecuregcm.storage.ReportMessageManager}. - * - * @return a list of reported message listeners controlled by the spam filter - */ - List getReportedMessageListeners(); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/sqs/DirectoryQueue.java b/service/src/main/java/org/whispersystems/textsecuregcm/sqs/DirectoryQueue.java deleted file mode 100644 index 0887efeea..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/sqs/DirectoryQueue.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.sqs; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.lifecycle.Managed; -import io.micrometer.core.instrument.Metrics; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.SqsConfiguration; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.util.Constants; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.core.exception.SdkClientException; -import software.amazon.awssdk.core.exception.SdkServiceException; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.sqs.SqsAsyncClient; -import software.amazon.awssdk.services.sqs.model.MessageAttributeValue; -import software.amazon.awssdk.services.sqs.model.SendMessageRequest; - -public class DirectoryQueue implements Managed { - - private static final Logger logger = LoggerFactory.getLogger(DirectoryQueue.class); - - private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private final Meter serviceErrorMeter = metricRegistry.meter(name(DirectoryQueue.class, "serviceError")); - private final Meter clientErrorMeter = metricRegistry.meter(name(DirectoryQueue.class, "clientError")); - private final Timer sendMessageBatchTimer = metricRegistry.timer(name(DirectoryQueue.class, "sendMessageBatch")); - - private final List queueUrls; - private final SqsAsyncClient sqs; - - private final AtomicInteger outstandingRequests = new AtomicInteger(); - - private enum UpdateAction { - ADD("add"), - DELETE("delete"); - - private final String action; - - UpdateAction(final String action) { - this.action = action; - } - - public MessageAttributeValue toMessageAttributeValue() { - return MessageAttributeValue.builder().dataType("String").stringValue(action).build(); - } - } - - public DirectoryQueue(SqsConfiguration sqsConfig) { - StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create( - sqsConfig.getAccessKey(), sqsConfig.getAccessSecret())); - - this.queueUrls = sqsConfig.getQueueUrls(); - - this.sqs = SqsAsyncClient.builder() - .region(Region.of(sqsConfig.getRegion())) - .credentialsProvider(credentialsProvider) - .build(); - - Metrics.gauge(name(getClass(), "outstandingRequests"), outstandingRequests); - } - - @VisibleForTesting - DirectoryQueue(final List queueUrls, final SqsAsyncClient sqs) { - this.queueUrls = queueUrls; - this.sqs = sqs; - } - - @Override - public void start() throws Exception { - } - - @Override - public void stop() throws Exception { - synchronized (outstandingRequests) { - while (outstandingRequests.get() > 0) { - outstandingRequests.wait(); - } - } - - sqs.close(); - } - - public void refreshAccount(final Account account) { - sendUpdateMessage(account.getUuid(), account.getNumber(), - account.shouldBeVisibleInDirectory() ? UpdateAction.ADD : UpdateAction.DELETE); - } - - public void deleteAccount(final Account account) { - sendUpdateMessage(account.getUuid(), account.getNumber(), UpdateAction.DELETE); - } - - public void changePhoneNumber(final Account account, final String originalNumber, final String newNumber) { - sendUpdateMessage(account.getUuid(), originalNumber, UpdateAction.DELETE); - sendUpdateMessage(account.getUuid(), newNumber, account.shouldBeVisibleInDirectory() ? UpdateAction.ADD : UpdateAction.DELETE); - } - - private void sendUpdateMessage(final UUID uuid, final String number, final UpdateAction action) { - for (final String queueUrl : queueUrls) { - final Timer.Context timerContext = sendMessageBatchTimer.time(); - - final SendMessageRequest request = SendMessageRequest.builder() - .queueUrl(queueUrl) - .messageBody("-") - .messageDeduplicationId(UUID.randomUUID().toString()) - .messageGroupId(number) - .messageAttributes(Map.of( - "id", MessageAttributeValue.builder().dataType("String").stringValue(number).build(), - "uuid", MessageAttributeValue.builder().dataType("String").stringValue(uuid.toString()).build(), - "action", action.toMessageAttributeValue() - )) - .build(); - - synchronized (outstandingRequests) { - outstandingRequests.incrementAndGet(); - } - - sqs.sendMessage(request).whenComplete((response, cause) -> { - try { - if (cause instanceof SdkServiceException) { - serviceErrorMeter.mark(); - logger.warn("sqs service error", cause); - } else if (cause instanceof SdkClientException) { - clientErrorMeter.mark(); - logger.warn("sqs client error", cause); - } else if (cause != null) { - logger.warn("sqs unexpected error", cause); - } - } finally { - synchronized (outstandingRequests) { - outstandingRequests.decrementAndGet(); - outstandingRequests.notifyAll(); - } - - timerContext.close(); - } - }); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AbstractDynamoDbStore.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AbstractDynamoDbStore.java deleted file mode 100644 index 313985860..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AbstractDynamoDbStore.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; -import static io.micrometer.core.instrument.Metrics.counter; -import static io.micrometer.core.instrument.Metrics.timer; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Timer; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemResponse; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.awssdk.services.dynamodb.model.WriteRequest; -import javax.annotation.Nonnull; - -public abstract class AbstractDynamoDbStore { - - private static final int MAX_ATTEMPTS_TO_SAVE_BATCH_WRITE = 25; // This was arbitrarily chosen and may be entirely too high. - - 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. - - public static final int RESULT_SET_CHUNK_SIZE = 100; - - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private final Timer batchWriteItemsFirstPass = timer(name(getClass(), "batchWriteItems"), "firstAttempt", "true"); - - private final Timer batchWriteItemsRetryPass = timer(name(getClass(), "batchWriteItems"), "firstAttempt", "false"); - - private final Counter batchWriteItemsUnprocessed = counter(name(getClass(), "batchWriteItemsUnprocessed")); - - private final DynamoDbClient dynamoDbClient; - - - public AbstractDynamoDbStore(final DynamoDbClient dynamoDbClient) { - this.dynamoDbClient = dynamoDbClient; - } - - protected DynamoDbClient db() { - return dynamoDbClient; - } - - protected void executeTableWriteItemsUntilComplete(final Map> items) { - final AtomicReference outcome = new AtomicReference<>(); - writeAndStoreOutcome(items, batchWriteItemsFirstPass, outcome); - int attemptCount = 0; - while (!outcome.get().unprocessedItems().isEmpty() && attemptCount < MAX_ATTEMPTS_TO_SAVE_BATCH_WRITE) { - writeAndStoreOutcome(outcome.get().unprocessedItems(), batchWriteItemsRetryPass, outcome); - ++attemptCount; - } - if (!outcome.get().unprocessedItems().isEmpty()) { - final int totalItems = outcome.get().unprocessedItems().values().stream().mapToInt(List::size).sum(); - logger.error( - "Attempt count ({}) reached max ({}}) before applying all batch writes to dynamo. {} unprocessed items remain.", - attemptCount, MAX_ATTEMPTS_TO_SAVE_BATCH_WRITE, totalItems); - batchWriteItemsUnprocessed.increment(totalItems); - } - } - - @Nonnull - protected List> scan(final ScanRequest scanRequest, final int max) { - return db().scanPaginator(scanRequest) - .items() - .stream() - .limit(max) - .toList(); - } - - private void writeAndStoreOutcome( - final Map> items, - final Timer timer, - final AtomicReference outcome) { - timer.record( - () -> outcome.set(dynamoDbClient.batchWriteItem(BatchWriteItemRequest.builder().requestItems(items).build())) - ); - } - - static void writeInBatches(final Iterable items, final Consumer> action) { - final List batch = new ArrayList<>(DYNAMO_DB_MAX_BATCH_SIZE); - - for (final T item : items) { - batch.add(item); - - if (batch.size() == DYNAMO_DB_MAX_BATCH_SIZE) { - action.accept(batch); - batch.clear(); - } - } - if (!batch.isEmpty()) { - action.accept(batch); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java deleted file mode 100644 index 25719fae5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java +++ /dev/null @@ -1,515 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Clock; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Predicate; -import javax.annotation.Nullable; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; -import org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter; -import org.whispersystems.textsecuregcm.util.Util; - -public class Account { - - @JsonIgnore - private static final Logger logger = LoggerFactory.getLogger(Account.class); - - @JsonIgnore - private UUID uuid; - - @JsonProperty("pni") - private UUID phoneNumberIdentifier; - - @JsonProperty - private String number; - - @JsonProperty - @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) - @Nullable - private byte[] usernameHash; - - @JsonProperty - @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class) - @Nullable - private byte[] reservedUsernameHash; - - @JsonProperty - private List devices = new ArrayList<>(); - - @JsonProperty - private String identityKey; - - @JsonProperty("pniIdentityKey") - private String phoneNumberIdentityKey; - - @JsonProperty("cpv") - private String currentProfileVersion; - - @JsonProperty - private List badges = new ArrayList<>(); - - @JsonProperty - private String registrationLock; - - @JsonProperty - private String registrationLockSalt; - - @JsonProperty("uak") - private byte[] unidentifiedAccessKey; - - @JsonProperty("uua") - private boolean unrestrictedUnidentifiedAccess; - - @JsonProperty("inCds") - private boolean discoverableByPhoneNumber = true; - - @JsonProperty - private int version; - - @JsonIgnore - private boolean stale; - - @JsonIgnore - private boolean canonicallyDiscoverable; - - - public UUID getUuid() { - // this is the one method that may be called on a stale account - return uuid; - } - - public void setUuid(UUID uuid) { - requireNotStale(); - - this.uuid = uuid; - } - - public UUID getPhoneNumberIdentifier() { - requireNotStale(); - - return phoneNumberIdentifier; - } - - /** - * Tests whether this account's account identifier or phone number identifier matches the given UUID. - * - * @param identifier the identifier to test - * @return {@code true} if this account's identifier or phone number identifier matches - */ - public boolean isIdentifiedBy(final UUID identifier) { - return uuid.equals(identifier) || (phoneNumberIdentifier != null && phoneNumberIdentifier.equals(identifier)); - } - - public String getNumber() { - requireNotStale(); - - return number; - } - - public void setNumber(String number, UUID phoneNumberIdentifier) { - requireNotStale(); - - this.number = number; - this.phoneNumberIdentifier = phoneNumberIdentifier; - } - - public Optional getUsernameHash() { - requireNotStale(); - - return Optional.ofNullable(usernameHash); - } - - public void setUsernameHash(final byte[] usernameHash) { - requireNotStale(); - - this.usernameHash = usernameHash; - } - - public Optional getReservedUsernameHash() { - requireNotStale(); - - return Optional.ofNullable(reservedUsernameHash); - } - - public void setReservedUsernameHash(final byte[] reservedUsernameHash) { - requireNotStale(); - - this.reservedUsernameHash = reservedUsernameHash; - } - - public void addDevice(Device device) { - requireNotStale(); - - removeDevice(device.getId()); - this.devices.add(device); - } - - public void removeDevice(long deviceId) { - requireNotStale(); - - this.devices.removeIf(device -> device.getId() == deviceId); - } - - public List getDevices() { - requireNotStale(); - - return devices; - } - - public Optional getMasterDevice() { - requireNotStale(); - - return getDevice(Device.MASTER_ID); - } - - public Optional getDevice(long deviceId) { - requireNotStale(); - - return devices.stream().filter(device -> device.getId() == deviceId).findFirst(); - } - - public boolean isStorageSupported() { - requireNotStale(); - - return devices.stream().anyMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStorage()); - } - - public boolean isTransferSupported() { - requireNotStale(); - - return getMasterDevice().map(Device::getCapabilities).map(Device.DeviceCapabilities::isTransfer).orElse(false); - } - - public boolean isSenderKeySupported() { - return allEnabledDevicesHaveCapability(DeviceCapabilities::isSenderKey); - } - - public boolean isAnnouncementGroupSupported() { - return allEnabledDevicesHaveCapability(DeviceCapabilities::isAnnouncementGroup); - } - - public boolean isChangeNumberSupported() { - return allEnabledDevicesHaveCapability(DeviceCapabilities::isChangeNumber); - } - - public boolean isPniSupported() { - return allEnabledDevicesHaveCapability(DeviceCapabilities::isPni); - } - - public boolean isStoriesSupported() { - requireNotStale(); - - return devices.stream() - .filter(Device::isEnabled) - .allMatch(device -> device.getCapabilities() != null && device.getCapabilities().isStories()); - } - - public boolean isGiftBadgesSupported() { - return allEnabledDevicesHaveCapability(DeviceCapabilities::isGiftBadges); - } - - public boolean isPaymentActivationSupported() { - return allEnabledDevicesHaveCapability(DeviceCapabilities::isPaymentActivation); - } - - private boolean allEnabledDevicesHaveCapability(Predicate predicate) { - requireNotStale(); - - return devices.stream() - .filter(Device::isEnabled) - .allMatch(device -> device.getCapabilities() != null && predicate.test(device.getCapabilities())); - } - - public boolean isEnabled() { - requireNotStale(); - - return getMasterDevice().map(Device::isEnabled).orElse(false); - } - - public long getNextDeviceId() { - requireNotStale(); - - long candidateId = Device.MASTER_ID + 1; - - while (getDevice(candidateId).isPresent()) { - candidateId++; - } - - return candidateId; - } - - public int getEnabledDeviceCount() { - requireNotStale(); - - int count = 0; - - for (Device device : devices) { - if (device.isEnabled()) count++; - } - - return count; - } - - public boolean isCanonicallyDiscoverable() { - requireNotStale(); - - return canonicallyDiscoverable; - } - - public void setCanonicallyDiscoverable(boolean canonicallyDiscoverable) { - requireNotStale(); - - this.canonicallyDiscoverable = canonicallyDiscoverable; - } - - public void setIdentityKey(String identityKey) { - requireNotStale(); - - this.identityKey = identityKey; - } - - public String getIdentityKey() { - requireNotStale(); - - return identityKey; - } - - public String getPhoneNumberIdentityKey() { - return phoneNumberIdentityKey; - } - - public void setPhoneNumberIdentityKey(final String phoneNumberIdentityKey) { - this.phoneNumberIdentityKey = phoneNumberIdentityKey; - } - - public long getLastSeen() { - requireNotStale(); - return devices.stream() - .map(Device::getLastSeen) - .max(Long::compare) - .orElse(0L); - } - - public Optional getCurrentProfileVersion() { - requireNotStale(); - - return Optional.ofNullable(currentProfileVersion); - } - - public void setCurrentProfileVersion(String currentProfileVersion) { - requireNotStale(); - - this.currentProfileVersion = currentProfileVersion; - } - - public List getBadges() { - requireNotStale(); - - return badges; - } - - public void setBadges(Clock clock, List badges) { - requireNotStale(); - - this.badges = badges; - - purgeStaleBadges(clock); - } - - public void addBadge(Clock clock, AccountBadge badge) { - requireNotStale(); - boolean added = false; - for (int i = 0; i < badges.size(); i++) { - AccountBadge badgeInList = badges.get(i); - if (Objects.equals(badgeInList.getId(), badge.getId())) { - if (added) { - badges.remove(i); - i--; - } else { - badges.set(i, badgeInList.mergeWith(badge)); - added = true; - } - } - } - - if (!added) { - badges.add(badge); - } - - purgeStaleBadges(clock); - } - - public void makeBadgePrimaryIfExists(Clock clock, String badgeId) { - requireNotStale(); - - // early exit if it's already the first item in the list - if (!badges.isEmpty() && Objects.equals(badges.get(0).getId(), badgeId)) { - purgeStaleBadges(clock); - return; - } - - int indexOfBadge = -1; - for (int i = 1; i < badges.size(); i++) { - if (Objects.equals(badgeId, badges.get(i).getId())) { - indexOfBadge = i; - break; - } - } - - if (indexOfBadge != -1) { - badges.add(0, badges.remove(indexOfBadge)); - } - - purgeStaleBadges(clock); - } - - public void removeBadge(Clock clock, String id) { - requireNotStale(); - - badges.removeIf(accountBadge -> Objects.equals(accountBadge.getId(), id)); - purgeStaleBadges(clock); - } - - private void purgeStaleBadges(Clock clock) { - final Instant now = clock.instant(); - badges.removeIf(accountBadge -> now.isAfter(accountBadge.getExpiration())); - } - - public void setRegistrationLockFromAttributes(final AccountAttributes attributes) { - if (!Util.isEmpty(attributes.getRegistrationLock())) { - SaltedTokenHash credentials = SaltedTokenHash.generateFor(attributes.getRegistrationLock()); - setRegistrationLock(credentials.hash(), credentials.salt()); - } else { - setRegistrationLock(null, null); - } - } - - public void setRegistrationLock(String registrationLock, String registrationLockSalt) { - requireNotStale(); - - this.registrationLock = registrationLock; - this.registrationLockSalt = registrationLockSalt; - } - - public StoredRegistrationLock getRegistrationLock() { - requireNotStale(); - - return new StoredRegistrationLock(Optional.ofNullable(registrationLock), Optional.ofNullable(registrationLockSalt), getLastSeen()); - } - - public Optional getUnidentifiedAccessKey() { - requireNotStale(); - - return Optional.ofNullable(unidentifiedAccessKey); - } - - public void setUnidentifiedAccessKey(byte[] unidentifiedAccessKey) { - requireNotStale(); - - this.unidentifiedAccessKey = unidentifiedAccessKey; - } - - public boolean isUnrestrictedUnidentifiedAccess() { - requireNotStale(); - - return unrestrictedUnidentifiedAccess; - } - - public void setUnrestrictedUnidentifiedAccess(boolean unrestrictedUnidentifiedAccess) { - requireNotStale(); - - this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess; - } - - public boolean isDiscoverableByPhoneNumber() { - requireNotStale(); - - return this.discoverableByPhoneNumber; - } - - public void setDiscoverableByPhoneNumber(final boolean discoverableByPhoneNumber) { - requireNotStale(); - - this.discoverableByPhoneNumber = discoverableByPhoneNumber; - } - - public boolean shouldBeVisibleInDirectory() { - requireNotStale(); - - return isEnabled() && isDiscoverableByPhoneNumber(); - } - - public int getVersion() { - requireNotStale(); - - return version; - } - - public void setVersion(int version) { - requireNotStale(); - - this.version = version; - } - - - /** - * Have all this account's devices been manually locked? - * - * @see Device#hasLockedCredentials - * - * @return true if all the account's devices were locked, false otherwise. - */ - public boolean hasLockedCredentials() { - return devices.stream().allMatch(Device::hasLockedCredentials); - } - - /** - * Lock account by invalidating authentication tokens. - * - * We only want to do this in cases where there is a potential conflict between the - * phone number holder and the registration lock holder. In that case, locking the - * account will ensure that either the registration lock holder proves ownership - * of the phone number, or after 7 days the phone number holder can register a new - * account. - */ - public void lockAuthTokenHash() { - devices.forEach(Device::lockAuthTokenHash); - } - - boolean isStale() { - return stale; - } - - public void markStale() { - stale = true; - } - - private void requireNotStale() { - assert !stale; - - //noinspection ConstantConditions - if (stale) { - logger.error("Accessor called on stale account", new RuntimeException()); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountBadge.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountBadge.java deleted file mode 100644 index c058ed742..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountBadge.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.time.Instant; -import java.util.Objects; - -public class AccountBadge { - - private final String id; - private final Instant expiration; - private final boolean visible; - - @JsonCreator - public AccountBadge( - @JsonProperty("id") String id, - @JsonProperty("expiration") Instant expiration, - @JsonProperty("visible") boolean visible) { - this.id = id; - this.expiration = expiration; - this.visible = visible; - } - - /** - * Returns a new AccountBadge that is a merging of the two originals. IDs must match for this operation to make sense. - * The expiration will be the later of the two. - * Visibility will be set if either of the passed in objects is visible. - */ - public AccountBadge mergeWith(AccountBadge other) { - if (!Objects.equals(other.id, id)) { - throw new IllegalArgumentException("merging badges should only take place for same id"); - } - - final Instant latestExpiration; - if (expiration == null || other.expiration == null) { - latestExpiration = null; - } else if (expiration.isAfter(other.expiration)) { - latestExpiration = expiration; - } else { - latestExpiration = other.expiration; - } - - return new AccountBadge( - id, - latestExpiration, - visible || other.visible - ); - } - - public AccountBadge withVisibility(boolean visible) { - if (this.visible == visible) { - return this; - } else { - return new AccountBadge( - this.id, - this.expiration, - visible); - } - } - - public String getId() { - return id; - } - - public Instant getExpiration() { - return expiration; - } - - public boolean isVisible() { - return visible; - } - - @Override - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - AccountBadge that = (AccountBadge) o; - return visible == that.visible && Objects.equals(id, that.id) - && Objects.equals(expiration, that.expiration); - } - - @Override - public int hashCode() { - return Objects.hash(id, expiration, visible); - } - - @Override - public String toString() { - return "AccountBadge{" + - "id='" + id + '\'' + - ", expiration=" + expiration + - ", visible=" + visible + - '}'; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidator.java deleted file mode 100644 index 507b6c0a8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidator.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Optionals; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Arrays; - - -class AccountChangeValidator { - - private final boolean allowNumberChange; - private final boolean allowUsernameHashChange; - - static final AccountChangeValidator GENERAL_CHANGE_VALIDATOR = new AccountChangeValidator(false, false); - static final AccountChangeValidator NUMBER_CHANGE_VALIDATOR = new AccountChangeValidator(true, false); - static final AccountChangeValidator USERNAME_CHANGE_VALIDATOR = new AccountChangeValidator(false, true); - - private static final Logger logger = LoggerFactory.getLogger(AccountChangeValidator.class); - - AccountChangeValidator(final boolean allowNumberChange, - final boolean allowUsernameHashChange) { - - this.allowNumberChange = allowNumberChange; - this.allowUsernameHashChange = allowUsernameHashChange; - } - - public void validateChange(final Account originalAccount, final Account updatedAccount) { - if (!allowNumberChange) { - assert updatedAccount.getNumber().equals(originalAccount.getNumber()); - - if (!updatedAccount.getNumber().equals(originalAccount.getNumber())) { - logger.error("Account number changed via \"normal\" update; numbers must be changed via changeNumber method", - new RuntimeException()); - } - - assert updatedAccount.getPhoneNumberIdentifier().equals(originalAccount.getPhoneNumberIdentifier()); - - if (!updatedAccount.getPhoneNumberIdentifier().equals(originalAccount.getPhoneNumberIdentifier())) { - logger.error( - "Phone number identifier changed via \"normal\" update; PNIs must be changed via changeNumber method", - new RuntimeException()); - } - } - - if (!allowUsernameHashChange) { - // We can potentially replace this with the actual hash of some invalid username (e.g. 1nickname.123) - final byte[] dummyHash = new byte[32]; - new SecureRandom().nextBytes(dummyHash); - - final byte[] updatedAccountUsernameHash = updatedAccount.getUsernameHash().orElse(dummyHash); - final byte[] originalAccountUsernameHash = originalAccount.getUsernameHash().orElse(dummyHash); - - boolean usernameUnchanged = MessageDigest.isEqual(updatedAccountUsernameHash, originalAccountUsernameHash); - - if (!usernameUnchanged) { - logger.error("Username hash changed via \"normal\" update; username hashes must be changed via reserveUsernameHash and confirmUsernameHash methods", - new RuntimeException()); - } - assert usernameUnchanged; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountCleaner.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountCleaner.java deleted file mode 100644 index 7e5340236..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountCleaner.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class AccountCleaner extends AccountDatabaseCrawlerListener { - - private static final Logger log = LoggerFactory.getLogger(AccountCleaner.class); - - private static final Counter DELETED_ACCOUNT_COUNTER = Metrics.counter(name(AccountCleaner.class, "deletedAccounts")); - - private final AccountsManager accountsManager; - private final Executor deletionExecutor; - - public AccountCleaner(final AccountsManager accountsManager, final Executor deletionExecutor) { - this.accountsManager = accountsManager; - this.deletionExecutor = deletionExecutor; - } - - @Override - public void onCrawlStart() { - } - - @Override - public void onCrawlEnd(Optional fromUuid) { - } - - @Override - protected void onCrawlChunk(Optional fromUuid, List chunkAccounts) { - final List> deletionFutures = chunkAccounts.stream() - .filter(AccountCleaner::isExpired) - .map(account -> CompletableFuture.runAsync(() -> { - try { - accountsManager.delete(account, AccountsManager.DeletionReason.EXPIRED); - } catch (final InterruptedException e) { - throw new CompletionException(e); - } - }, deletionExecutor) - .whenComplete((ignored, throwable) -> { - if (throwable != null) { - log.warn("Failed to delete account {}", account.getUuid(), throwable); - } else { - DELETED_ACCOUNT_COUNTER.increment(); - } - })) - .toList(); - - try { - CompletableFuture.allOf(deletionFutures.toArray(new CompletableFuture[0])).join(); - } catch (final Exception e) { - log.debug("Failed to delete one or more accounts in chunk", e); - } - } - - private static boolean isExpired(Account account) { - return account.getLastSeen() + TimeUnit.DAYS.toMillis(365) < System.currentTimeMillis(); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountCrawlChunk.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountCrawlChunk.java deleted file mode 100644 index de6bc68c2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountCrawlChunk.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import javax.annotation.Nullable; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public class AccountCrawlChunk { - - private final List accounts; - @Nullable - private final UUID lastUuid; - - public AccountCrawlChunk(final List accounts, @Nullable final UUID lastUuid) { - this.accounts = accounts; - this.lastUuid = lastUuid; - } - - public List getAccounts() { - return accounts; - } - - public Optional getLastUuid() { - return Optional.ofNullable(lastUuid); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawler.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawler.java deleted file mode 100644 index daf5d00f2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawler.java +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.lifecycle.Managed; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.Util; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class AccountDatabaseCrawler implements Managed, Runnable { - - private static final Logger logger = LoggerFactory.getLogger(AccountDatabaseCrawler.class); - private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private static final Timer readChunkTimer = metricRegistry.timer(name(AccountDatabaseCrawler.class, "readChunk")); - private static final Timer processChunkTimer = metricRegistry.timer( - name(AccountDatabaseCrawler.class, "processChunk")); - - private static final long WORKER_TTL_MS = 120_000L; - private static final long ACCELERATED_CHUNK_INTERVAL = 10L; - - private final String name; - private final AccountsManager accounts; - private final int chunkSize; - private final long chunkIntervalMs; - private final String workerId; - private final AccountDatabaseCrawlerCache cache; - private final List listeners; - - private AtomicBoolean running = new AtomicBoolean(false); - private boolean finished; - - public AccountDatabaseCrawler(final String name, - AccountsManager accounts, - AccountDatabaseCrawlerCache cache, - List listeners, - int chunkSize, - long chunkIntervalMs) { - this.name = name; - this.accounts = accounts; - this.chunkSize = chunkSize; - this.chunkIntervalMs = chunkIntervalMs; - this.workerId = UUID.randomUUID().toString(); - this.cache = cache; - this.listeners = listeners; - - } - - @Override - public synchronized void start() { - running.set(true); - new Thread(this).start(); - } - - @Override - public synchronized void stop() { - running.set(false); - notifyAll(); - while (!finished) { - Util.wait(this); - } - } - - @Override - public void run() { - boolean accelerated = false; - - while (running.get()) { - try { - accelerated = doPeriodicWork(); - sleepWhileRunning(accelerated ? ACCELERATED_CHUNK_INTERVAL : chunkIntervalMs); - } catch (Throwable t) { - logger.warn("{}: error in database crawl: {}: {}", name, t.getClass().getSimpleName(), t.getMessage(), t); - Util.sleep(10000); - } - } - - synchronized (this) { - finished = true; - notifyAll(); - } - } - - @VisibleForTesting - public boolean doPeriodicWork() { - if (cache.claimActiveWork(workerId, WORKER_TTL_MS)) { - - try { - final long startTimeMs = System.currentTimeMillis(); - processChunk(); - if (cache.isAccelerated()) { - return true; - } - final long endTimeMs = System.currentTimeMillis(); - final long sleepIntervalMs = chunkIntervalMs - (endTimeMs - startTimeMs); - if (sleepIntervalMs > 0) { - logger.debug("{}: Sleeping {}ms", name, sleepIntervalMs); - sleepWhileRunning(sleepIntervalMs); - } - } finally { - cache.releaseActiveWork(workerId); - } - } - return false; - } - - private void processChunk() { - - try (Timer.Context timer = processChunkTimer.time()) { - - final Optional fromUuid = getLastUuid(); - - if (fromUuid.isEmpty()) { - logger.info("{}: Started crawl", name); - listeners.forEach(AccountDatabaseCrawlerListener::onCrawlStart); - } - - final AccountCrawlChunk chunkAccounts = readChunk(fromUuid, chunkSize); - - if (chunkAccounts.getAccounts().isEmpty()) { - logger.info("{}: Finished crawl", name); - listeners.forEach(listener -> listener.onCrawlEnd(fromUuid)); - cacheLastUuid(Optional.empty()); - cache.setAccelerated(false); - } else { - logger.debug("{}: Processing chunk", name); - try { - for (AccountDatabaseCrawlerListener listener : listeners) { - listener.timeAndProcessCrawlChunk(fromUuid, chunkAccounts.getAccounts()); - } - cacheLastUuid(chunkAccounts.getLastUuid()); - } catch (AccountDatabaseCrawlerRestartException e) { - cacheLastUuid(Optional.empty()); - cache.setAccelerated(false); - } - } - } - } - - private AccountCrawlChunk readChunk(Optional fromUuid, int chunkSize) { - return readChunk(fromUuid, chunkSize, readChunkTimer); - } - - private AccountCrawlChunk readChunk(Optional fromUuid, int chunkSize, Timer readTimer) { - try (Timer.Context timer = readTimer.time()) { - - if (fromUuid.isPresent()) { - return accounts.getAllFromDynamo(fromUuid.get(), chunkSize); - } - - return accounts.getAllFromDynamo(chunkSize); - } - } - - private Optional getLastUuid() { - return cache.getLastUuidDynamo(); - } - - private void cacheLastUuid(final Optional lastUuid) { - cache.setLastUuidDynamo(lastUuid); - } - - private synchronized void sleepWhileRunning(long delayMs) { - if (running.get()) { - Util.wait(this, delayMs); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerCache.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerCache.java deleted file mode 100644 index 100da674f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerCache.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import io.lettuce.core.ScriptOutputType; -import io.lettuce.core.SetArgs; -import java.io.IOException; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class AccountDatabaseCrawlerCache { - - public static final String GENERAL_PURPOSE_PREFIX = ""; - public static final String DIRECTORY_RECONCILER_PREFIX = "directory-reconciler"; - public static final String ACCOUNT_CLEANER_PREFIX = "account-cleaner"; - - private static final String ACTIVE_WORKER_KEY = "account_database_crawler_cache_active_worker"; - private static final String LAST_UUID_KEY = "account_database_crawler_cache_last_uuid"; - private static final String ACCELERATE_KEY = "account_database_crawler_cache_accelerate"; - - private static final String LAST_UUID_DYNAMO_KEY = "account_database_crawler_cache_last_uuid_dynamo"; - - private static final long LAST_NUMBER_TTL_MS = 86400_000L; - - private final FaultTolerantRedisCluster cacheCluster; - private final ClusterLuaScript unlockClusterScript; - - private final String prefix; - - public AccountDatabaseCrawlerCache(FaultTolerantRedisCluster cacheCluster, String prefix) throws IOException { - this.cacheCluster = cacheCluster; - this.unlockClusterScript = ClusterLuaScript.fromResource(cacheCluster, "lua/account_database_crawler/unlock.lua", - ScriptOutputType.INTEGER); - - this.prefix = prefix + "::"; - } - - public void setAccelerated(final boolean accelerated) { - if (accelerated) { - cacheCluster.useCluster(connection -> connection.sync().set(getPrefixedKey(ACCELERATE_KEY), "1")); - } else { - cacheCluster.useCluster(connection -> connection.sync().del(getPrefixedKey(ACCELERATE_KEY))); - } - } - - public boolean isAccelerated() { - return "1".equals(cacheCluster.withCluster(connection -> connection.sync().get(ACCELERATE_KEY))); - } - - public boolean claimActiveWork(String workerId, long ttlMs) { - return "OK".equals(cacheCluster.withCluster(connection -> connection.sync() - .set(getPrefixedKey(ACTIVE_WORKER_KEY), workerId, SetArgs.Builder.nx().px(ttlMs)))); - } - - public void releaseActiveWork(String workerId) { - unlockClusterScript.execute(List.of(getPrefixedKey(ACTIVE_WORKER_KEY)), List.of(workerId)); - } - - public Optional getLastUuid() { - final String lastUuidString = cacheCluster.withCluster( - connection -> connection.sync().get(getPrefixedKey(LAST_UUID_KEY))); - - if (lastUuidString == null) { - return Optional.empty(); - } else { - return Optional.of(UUID.fromString(lastUuidString)); - } - } - - public void setLastUuid(Optional lastUuid) { - if (lastUuid.isPresent()) { - cacheCluster.useCluster(connection -> connection.sync() - .psetex(getPrefixedKey(LAST_UUID_KEY), LAST_NUMBER_TTL_MS, lastUuid.get().toString())); - } else { - cacheCluster.useCluster(connection -> connection.sync().del(getPrefixedKey(LAST_UUID_KEY))); - } - } - - public Optional getLastUuidDynamo() { - final String lastUuidString = cacheCluster.withCluster( - connection -> connection.sync().get(getPrefixedKey(LAST_UUID_DYNAMO_KEY))); - - if (lastUuidString == null) { - return Optional.empty(); - } else { - return Optional.of(UUID.fromString(lastUuidString)); - } - } - - public void setLastUuidDynamo(Optional lastUuid) { - if (lastUuid.isPresent()) { - cacheCluster.useCluster( - connection -> connection.sync() - .psetex(getPrefixedKey(LAST_UUID_DYNAMO_KEY), LAST_NUMBER_TTL_MS, lastUuid.get().toString())); - } else { - cacheCluster.useCluster(connection -> connection.sync().del(getPrefixedKey(LAST_UUID_DYNAMO_KEY))); - } - } - - private String getPrefixedKey(final String key) { - return prefix + key; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerListener.java deleted file mode 100644 index abfbb64e3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerListener.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; - -import org.whispersystems.textsecuregcm.util.Constants; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static com.codahale.metrics.MetricRegistry.name; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public abstract class AccountDatabaseCrawlerListener { - - private Timer processChunkTimer; - - abstract public void onCrawlStart(); - - abstract public void onCrawlEnd(Optional fromUuid); - - abstract protected void onCrawlChunk(Optional fromUuid, List chunkAccounts) throws AccountDatabaseCrawlerRestartException; - - public AccountDatabaseCrawlerListener() { - processChunkTimer = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME).timer(name(AccountDatabaseCrawlerListener.class, "processChunk", getClass().getSimpleName())); - } - - public void timeAndProcessCrawlChunk(Optional fromUuid, List chunkAccounts) throws AccountDatabaseCrawlerRestartException { - try (Timer.Context timer = processChunkTimer.time()) { - onCrawlChunk(fromUuid, chunkAccounts); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerRestartException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerRestartException.java deleted file mode 100644 index 6fcc39d05..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerRestartException.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -public class AccountDatabaseCrawlerRestartException extends Exception { - public AccountDatabaseCrawlerRestartException(String s) { - super(s); - } - - public AccountDatabaseCrawlerRestartException(Exception e) { - super(e); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java deleted file mode 100644 index a37e5a8c9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java +++ /dev/null @@ -1,875 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; -import static java.util.Objects.requireNonNull; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Throwables; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Clock; -import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletionException; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import javax.annotation.Nonnull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.ExceptionUtils; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.util.UUIDUtil; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.CancellationReason; -import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; -import software.amazon.awssdk.services.dynamodb.model.Delete; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.Put; -import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem; -import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest; -import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; -import software.amazon.awssdk.services.dynamodb.model.TransactionConflictException; -import software.amazon.awssdk.services.dynamodb.model.Update; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; -import software.amazon.awssdk.utils.CompletableFutureUtils; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class Accounts extends AbstractDynamoDbStore { - - private static final Logger log = LoggerFactory.getLogger(Accounts.class); - - private static final Timer CREATE_TIMER = Metrics.timer(name(Accounts.class, "create")); - private static final Timer CHANGE_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "changeNumber")); - private static final Timer SET_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "setUsername")); - private static final Timer RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, "reserveUsername")); - private static final Timer CLEAR_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "clearUsernameHash")); - private static final Timer UPDATE_TIMER = Metrics.timer(name(Accounts.class, "update")); - private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, "getByNumber")); - private static final Timer GET_BY_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, "getByUsernameHash")); - private static final Timer GET_BY_PNI_TIMER = Metrics.timer(name(Accounts.class, "getByPni")); - private static final Timer GET_BY_UUID_TIMER = Metrics.timer(name(Accounts.class, "getByUuid")); - private static final Timer GET_ALL_FROM_START_TIMER = Metrics.timer(name(Accounts.class, "getAllFrom")); - private static final Timer GET_ALL_FROM_OFFSET_TIMER = Metrics.timer(name(Accounts.class, "getAllFromOffset")); - private static final Timer DELETE_TIMER = Metrics.timer(name(Accounts.class, "delete")); - - private static final String CONDITIONAL_CHECK_FAILED = "ConditionalCheckFailed"; - - private static final String TRANSACTION_CONFLICT = "TransactionConflict"; - - // uuid, primary key - static final String KEY_ACCOUNT_UUID = "U"; - // uuid, attribute on account table, primary key for PNI table - static final String ATTR_PNI_UUID = "PNI"; - // phone number - static final String ATTR_ACCOUNT_E164 = "P"; - // account, serialized to JSON - static final String ATTR_ACCOUNT_DATA = "D"; - // internal version for optimistic locking - static final String ATTR_VERSION = "V"; - // canonically discoverable - static final String ATTR_CANONICALLY_DISCOVERABLE = "C"; - // username hash; byte[] or null - static final String ATTR_USERNAME_HASH = "N"; - // confirmed; bool - static final String ATTR_CONFIRMED = "F"; - // unidentified access key; byte[] or null - static final String ATTR_UAK = "UAK"; - // time to live; number - static final String ATTR_TTL = "TTL"; - - private final Clock clock; - - private final DynamoDbAsyncClient asyncClient; - - private final String phoneNumberConstraintTableName; - - private final String phoneNumberIdentifierConstraintTableName; - - private final String usernamesConstraintTableName; - - private final String accountsTableName; - - private final int scanPageSize; - - - @VisibleForTesting - public Accounts( - final Clock clock, - final DynamoDbClient client, - final DynamoDbAsyncClient asyncClient, - final String accountsTableName, - final String phoneNumberConstraintTableName, - final String phoneNumberIdentifierConstraintTableName, - final String usernamesConstraintTableName, - final int scanPageSize) { - super(client); - this.clock = clock; - this.asyncClient = asyncClient; - this.phoneNumberConstraintTableName = phoneNumberConstraintTableName; - this.phoneNumberIdentifierConstraintTableName = phoneNumberIdentifierConstraintTableName; - this.accountsTableName = accountsTableName; - this.usernamesConstraintTableName = usernamesConstraintTableName; - this.scanPageSize = scanPageSize; - } - - public Accounts( - final DynamoDbClient client, - final DynamoDbAsyncClient asyncClient, - final String accountsTableName, - final String phoneNumberConstraintTableName, - final String phoneNumberIdentifierConstraintTableName, - final String usernamesConstraintTableName, - final int scanPageSize) { - this(Clock.systemUTC(), client, asyncClient, accountsTableName, - phoneNumberConstraintTableName, phoneNumberIdentifierConstraintTableName, usernamesConstraintTableName, - scanPageSize); - } - - public boolean create(final Account account) { - return CREATE_TIMER.record(() -> { - try { - final AttributeValue uuidAttr = AttributeValues.fromUUID(account.getUuid()); - final AttributeValue numberAttr = AttributeValues.fromString(account.getNumber()); - final AttributeValue pniUuidAttr = AttributeValues.fromUUID(account.getPhoneNumberIdentifier()); - - final TransactWriteItem phoneNumberConstraintPut = buildConstraintTablePutIfAbsent( - phoneNumberConstraintTableName, uuidAttr, ATTR_ACCOUNT_E164, numberAttr); - - final TransactWriteItem phoneNumberIdentifierConstraintPut = buildConstraintTablePutIfAbsent( - phoneNumberIdentifierConstraintTableName, uuidAttr, ATTR_PNI_UUID, pniUuidAttr); - - final TransactWriteItem accountPut = buildAccountPut(account, uuidAttr, numberAttr, pniUuidAttr); - - final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder() - .transactItems(phoneNumberConstraintPut, phoneNumberIdentifierConstraintPut, accountPut) - .build(); - - try { - db().transactWriteItems(request); - } catch (final TransactionCanceledException e) { - - final CancellationReason accountCancellationReason = e.cancellationReasons().get(2); - - if (conditionalCheckFailed(accountCancellationReason)) { - throw new IllegalArgumentException("account identifier present with different phone number"); - } - - final CancellationReason phoneNumberConstraintCancellationReason = e.cancellationReasons().get(0); - final CancellationReason phoneNumberIdentifierConstraintCancellationReason = e.cancellationReasons().get(1); - - if (conditionalCheckFailed(phoneNumberConstraintCancellationReason) - || conditionalCheckFailed(phoneNumberIdentifierConstraintCancellationReason)) { - - // In theory, both reasons should trip in tandem and either should give us the information we need. Even so, - // we'll be cautious here and make sure we're choosing a condition check that really failed. - final CancellationReason reason = conditionalCheckFailed(phoneNumberConstraintCancellationReason) - ? phoneNumberConstraintCancellationReason - : phoneNumberIdentifierConstraintCancellationReason; - - final ByteBuffer actualAccountUuid = reason.item().get(KEY_ACCOUNT_UUID).b().asByteBuffer(); - account.setUuid(UUIDUtil.fromByteBuffer(actualAccountUuid)); - - final Account existingAccount = getByAccountIdentifier(account.getUuid()).orElseThrow(); - - // It's up to the client to delete this username hash if they can't retrieve and decrypt the plaintext username from storage service - existingAccount.getUsernameHash().ifPresent(existingUsernameHash -> account.setUsernameHash(existingUsernameHash)); - account.setNumber(existingAccount.getNumber(), existingAccount.getPhoneNumberIdentifier()); - account.setVersion(existingAccount.getVersion()); - - update(account); - - return false; - } - - if (TRANSACTION_CONFLICT.equals(accountCancellationReason.code())) { - // this should only happen if two clients manage to make concurrent create() calls - throw new ContestedOptimisticLockException(); - } - - // this shouldn't happen - throw new RuntimeException("could not create account: " + extractCancellationReasonCodes(e)); - } - } catch (final JsonProcessingException e) { - throw new IllegalArgumentException(e); - } - - return true; - }); - } - - /** - * Changes the phone number for the given account. The given account's number should be its current, pre-change - * number. If this method succeeds, the account's number will be changed to the new number and its phone number - * identifier will be changed to the given phone number identifier. If the update fails for any reason, the account's - * number and PNI will be unchanged. - *

- * This method expects that any accounts with conflicting numbers will have been removed by the time this method is - * called. This method may fail with an unspecified {@link RuntimeException} if another account with the same number - * exists in the data store. - * - * @param account the account for which to change the phone number - * @param number the new phone number - */ - public void changeNumber(final Account account, final String number, final UUID phoneNumberIdentifier) { - CHANGE_NUMBER_TIMER.record(() -> { - final String originalNumber = account.getNumber(); - final UUID originalPni = account.getPhoneNumberIdentifier(); - - boolean succeeded = false; - - account.setNumber(number, phoneNumberIdentifier); - - try { - final List writeItems = new ArrayList<>(); - final AttributeValue uuidAttr = AttributeValues.fromUUID(account.getUuid()); - final AttributeValue numberAttr = AttributeValues.fromString(number); - final AttributeValue pniAttr = AttributeValues.fromUUID(phoneNumberIdentifier); - - writeItems.add(buildDelete(phoneNumberConstraintTableName, ATTR_ACCOUNT_E164, originalNumber)); - writeItems.add(buildConstraintTablePut(phoneNumberConstraintTableName, uuidAttr, ATTR_ACCOUNT_E164, numberAttr)); - writeItems.add(buildDelete(phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, originalPni)); - writeItems.add(buildConstraintTablePut(phoneNumberIdentifierConstraintTableName, uuidAttr, ATTR_PNI_UUID, pniAttr)); - writeItems.add( - TransactWriteItem.builder() - .update(Update.builder() - .tableName(accountsTableName) - .key(Map.of(KEY_ACCOUNT_UUID, uuidAttr)) - .updateExpression( - "SET #data = :data, #number = :number, #pni = :pni, #cds = :cds ADD #version :version_increment") - .conditionExpression( - "attribute_exists(#number) AND #version = :version") - .expressionAttributeNames(Map.of( - "#number", ATTR_ACCOUNT_E164, - "#data", ATTR_ACCOUNT_DATA, - "#cds", ATTR_CANONICALLY_DISCOVERABLE, - "#pni", ATTR_PNI_UUID, - "#version", ATTR_VERSION)) - .expressionAttributeValues(Map.of( - ":number", numberAttr, - ":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)), - ":cds", AttributeValues.fromBool(account.shouldBeVisibleInDirectory()), - ":pni", pniAttr, - ":version", AttributeValues.fromInt(account.getVersion()), - ":version_increment", AttributeValues.fromInt(1))) - .build()) - .build()); - - final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder() - .transactItems(writeItems) - .build(); - - db().transactWriteItems(request); - - account.setVersion(account.getVersion() + 1); - succeeded = true; - } catch (final JsonProcessingException e) { - throw new IllegalArgumentException(e); - } finally { - if (!succeeded) { - account.setNumber(originalNumber, originalPni); - } - } - }); - } - - /** - * Reserve a username hash under the account UUID - */ - public void reserveUsernameHash( - final Account account, - final byte[] reservedUsernameHash, - final Duration ttl) { - final long startNanos = System.nanoTime(); - // if there is an existing old reservation it will be cleaned up via ttl - final Optional maybeOriginalReservation = account.getReservedUsernameHash(); - account.setReservedUsernameHash(reservedUsernameHash); - - boolean succeeded = false; - - final long expirationTime = clock.instant().plus(ttl).getEpochSecond(); - - // Use account UUID as a "reservation token" - by providing this, the client proves ownership of the hash - UUID uuid = account.getUuid(); - try { - final List writeItems = new ArrayList<>(); - - writeItems.add(TransactWriteItem.builder() - .put(Put.builder() - .tableName(usernamesConstraintTableName) - .item(Map.of( - KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid), - ATTR_USERNAME_HASH, AttributeValues.fromByteArray(reservedUsernameHash), - ATTR_TTL, AttributeValues.fromLong(expirationTime), - ATTR_CONFIRMED, AttributeValues.fromBool(false))) - .conditionExpression("attribute_not_exists(#username_hash) OR (#ttl < :now)") - .expressionAttributeNames(Map.of("#username_hash", ATTR_USERNAME_HASH, "#ttl", ATTR_TTL)) - .expressionAttributeValues(Map.of(":now", AttributeValues.fromLong(clock.instant().getEpochSecond()))) - .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD) - .build()) - .build()); - - writeItems.add( - TransactWriteItem.builder() - .update(Update.builder() - .tableName(accountsTableName) - .key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid))) - .updateExpression("SET #data = :data ADD #version :version_increment") - .conditionExpression("#version = :version") - .expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA, "#version", ATTR_VERSION)) - .expressionAttributeValues(Map.of( - ":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)), - ":version", AttributeValues.fromInt(account.getVersion()), - ":version_increment", AttributeValues.fromInt(1))) - .build()) - .build()); - - final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder() - .transactItems(writeItems) - .build(); - - db().transactWriteItems(request); - - account.setVersion(account.getVersion() + 1); - succeeded = true; - } catch (final JsonProcessingException e) { - throw new IllegalArgumentException(e); - } catch (final TransactionCanceledException e) { - if (e.cancellationReasons().stream().map(CancellationReason::code).anyMatch(CONDITIONAL_CHECK_FAILED::equals)) { - throw new ContestedOptimisticLockException(); - } - throw e; - } finally { - if (!succeeded) { - account.setReservedUsernameHash(maybeOriginalReservation.orElse(null)); - } - RESERVE_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); - } - } - - /** - * Confirm (set) a previously reserved username hash - * - * @param account to update - * @param usernameHash believed to be available - * @throws ContestedOptimisticLockException if the account has been updated or the username has taken by someone else - */ - public void confirmUsernameHash(final Account account, final byte[] usernameHash) - throws ContestedOptimisticLockException { - final long startNanos = System.nanoTime(); - - final Optional maybeOriginalUsernameHash = account.getUsernameHash(); - final Optional maybeOriginalReservationHash = account.getReservedUsernameHash(); - - account.setUsernameHash(usernameHash); - account.setReservedUsernameHash(null); - - boolean succeeded = false; - - try { - final List writeItems = new ArrayList<>(); - - // add the username hash to the constraint table, wiping out the ttl if we had already reserved the hash - writeItems.add(TransactWriteItem.builder() - .put(Put.builder() - .tableName(usernamesConstraintTableName) - .item(Map.of( - KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()), - ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash), - ATTR_CONFIRMED, AttributeValues.fromBool(true))) - // it's not in the constraint table OR it's expired OR it was reserved by us - .conditionExpression("attribute_not_exists(#username_hash) OR #ttl < :now OR (#aci = :aci AND #confirmed = :confirmed)") - .expressionAttributeNames(Map.of("#username_hash", ATTR_USERNAME_HASH, "#ttl", ATTR_TTL, "#aci", KEY_ACCOUNT_UUID, "#confirmed", ATTR_CONFIRMED)) - .expressionAttributeValues(Map.of( - ":now", AttributeValues.fromLong(clock.instant().getEpochSecond()), - ":aci", AttributeValues.fromUUID(account.getUuid()), - ":confirmed", AttributeValues.fromBool(false))) - .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD) - .build()) - .build()); - - writeItems.add( - TransactWriteItem.builder() - .update(Update.builder() - .tableName(accountsTableName) - .key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()))) - .updateExpression("SET #data = :data, #username_hash = :username_hash ADD #version :version_increment") - .conditionExpression("#version = :version") - .expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA, - "#username_hash", ATTR_USERNAME_HASH, - "#version", ATTR_VERSION)) - .expressionAttributeValues(Map.of( - ":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)), - ":username_hash", AttributeValues.fromByteArray(usernameHash), - ":version", AttributeValues.fromInt(account.getVersion()), - ":version_increment", AttributeValues.fromInt(1))) - .build()) - .build()); - - maybeOriginalUsernameHash.ifPresent(originalUsernameHash -> writeItems.add( - buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, originalUsernameHash))); - - final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder() - .transactItems(writeItems) - .build(); - - db().transactWriteItems(request); - - account.setVersion(account.getVersion() + 1); - succeeded = true; - } catch (final JsonProcessingException e) { - throw new IllegalArgumentException(e); - } catch (final TransactionCanceledException e) { - if (e.cancellationReasons().stream().map(CancellationReason::code).anyMatch(CONDITIONAL_CHECK_FAILED::equals)) { - throw new ContestedOptimisticLockException(); - } - throw e; - } finally { - if (!succeeded) { - account.setUsernameHash(maybeOriginalUsernameHash.orElse(null)); - account.setReservedUsernameHash(maybeOriginalReservationHash.orElse(null)); - } - SET_USERNAME_TIMER.record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); - } - } - - public void clearUsernameHash(final Account account) { - account.getUsernameHash().ifPresent(usernameHash -> { - CLEAR_USERNAME_HASH_TIMER.record(() -> { - account.setUsernameHash(null); - - boolean succeeded = false; - - try { - final List writeItems = new ArrayList<>(); - - writeItems.add( - TransactWriteItem.builder() - .update(Update.builder() - .tableName(accountsTableName) - .key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()))) - .updateExpression("SET #data = :data REMOVE #username_hash ADD #version :version_increment") - .conditionExpression("#version = :version") - .expressionAttributeNames(Map.of("#data", ATTR_ACCOUNT_DATA, - "#username_hash", ATTR_USERNAME_HASH, - "#version", ATTR_VERSION)) - .expressionAttributeValues(Map.of( - ":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)), - ":version", AttributeValues.fromInt(account.getVersion()), - ":version_increment", AttributeValues.fromInt(1))) - .build()) - .build()); - - writeItems.add(buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, usernameHash)); - - final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder() - .transactItems(writeItems) - .build(); - - db().transactWriteItems(request); - - account.setVersion(account.getVersion() + 1); - succeeded = true; - } catch (final JsonProcessingException e) { - throw new IllegalArgumentException(e); - } catch (final TransactionCanceledException e) { - if (conditionalCheckFailed(e.cancellationReasons().get(0))) { - throw new ContestedOptimisticLockException(); - } - - throw e; - } finally { - if (!succeeded) { - account.setUsernameHash(usernameHash); - } - } - }); - }); - } - - @Nonnull - public CompletionStage updateAsync(final Account account) { - return record(UPDATE_TIMER, () -> { - final UpdateItemRequest updateItemRequest; - try { - // username, e164, and pni cannot be modified through this method - final Map attrNames = new HashMap<>(Map.of( - "#number", ATTR_ACCOUNT_E164, - "#data", ATTR_ACCOUNT_DATA, - "#cds", ATTR_CANONICALLY_DISCOVERABLE, - "#version", ATTR_VERSION)); - final Map attrValues = new HashMap<>(Map.of( - ":data", AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)), - ":cds", AttributeValues.fromBool(account.shouldBeVisibleInDirectory()), - ":version", AttributeValues.fromInt(account.getVersion()), - ":version_increment", AttributeValues.fromInt(1))); - - final String updateExpression; - if (account.getUnidentifiedAccessKey().isPresent()) { - // if it's present in the account, also set the uak - attrNames.put("#uak", ATTR_UAK); - attrValues.put(":uak", AttributeValues.fromByteArray(account.getUnidentifiedAccessKey().get())); - updateExpression = "SET #data = :data, #cds = :cds, #uak = :uak ADD #version :version_increment"; - } else { - updateExpression = "SET #data = :data, #cds = :cds ADD #version :version_increment"; - } - - updateItemRequest = UpdateItemRequest.builder() - .tableName(accountsTableName) - .key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()))) - .updateExpression(updateExpression) - .conditionExpression("attribute_exists(#number) AND #version = :version") - .expressionAttributeNames(attrNames) - .expressionAttributeValues(attrValues) - .build(); - } catch (final JsonProcessingException e) { - throw new IllegalArgumentException(e); - } - - return asyncClient.updateItem(updateItemRequest) - .thenApply(response -> { - account.setVersion(AttributeValues.getInt(response.attributes(), "V", account.getVersion() + 1)); - return (Void) null; - }) - .exceptionally(throwable -> { - final Throwable unwrapped = ExceptionUtils.unwrap(throwable); - if (unwrapped instanceof TransactionConflictException) { - throw new ContestedOptimisticLockException(); - } else if (unwrapped instanceof ConditionalCheckFailedException e) { - // the exception doesn't give details about which condition failed, - // but we can infer it was an optimistic locking failure if the UUID is known - throw getByAccountIdentifier(account.getUuid()).isPresent() ? new ContestedOptimisticLockException() : e; - } else { - // rethrow - throw CompletableFutureUtils.errorAsCompletionException(throwable); - } - }); - }); - } - - public void update(final Account account) throws ContestedOptimisticLockException { - try { - updateAsync(account).toCompletableFuture().join(); - } catch (final CompletionException e) { - // unwrap CompletionExceptions, throw as long is it's unchecked - Throwables.throwIfUnchecked(ExceptionUtils.unwrap(e)); - - // if we otherwise somehow got a wrapped checked exception, - // rethrow the checked exception wrapped by the original CompletionException - log.error("Unexpected checked exception thrown from dynamo update", e); - throw e; - } - } - - public boolean usernameHashAvailable(final byte[] username) { - return usernameHashAvailable(Optional.empty(), username); - } - - public boolean usernameHashAvailable(final Optional accountUuid, final byte[] usernameHash) { - final Optional> usernameHashItem = itemByKey( - usernamesConstraintTableName, ATTR_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash)); - - if (usernameHashItem.isEmpty()) { - // username hash is free - return true; - } - final Map item = usernameHashItem.get(); - - if (AttributeValues.getLong(item, ATTR_TTL, Long.MAX_VALUE) < clock.instant().getEpochSecond()) { - // username hash was reserved, but has expired - return true; - } - - // username hash is reserved by us - return !AttributeValues.getBool(item, ATTR_CONFIRMED, true) && accountUuid - .map(AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, new UUID(0, 0))::equals) - .orElse(false); - } - - @Nonnull - public Optional getByE164(final String number) { - return getByIndirectLookup( - GET_BY_NUMBER_TIMER, phoneNumberConstraintTableName, ATTR_ACCOUNT_E164, AttributeValues.fromString(number)); - } - - @Nonnull - public Optional getByPhoneNumberIdentifier(final UUID phoneNumberIdentifier) { - return getByIndirectLookup( - GET_BY_PNI_TIMER, phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, AttributeValues.fromUUID(phoneNumberIdentifier)); - } - - @Nonnull - public Optional getByUsernameHash(final byte[] usernameHash) { - return getByIndirectLookup( - GET_BY_USERNAME_HASH_TIMER, - usernamesConstraintTableName, - ATTR_USERNAME_HASH, - AttributeValues.fromByteArray(usernameHash), - item -> AttributeValues.getBool(item, ATTR_CONFIRMED, false) // ignore items that are reservations (not confirmed) - ); - } - - @Nonnull - public Optional getByAccountIdentifier(final UUID uuid) { - return requireNonNull(GET_BY_UUID_TIMER.record(() -> - itemByKey(accountsTableName, KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid)) - .map(Accounts::fromItem))); - } - - public void delete(final UUID uuid) { - DELETE_TIMER.record(() -> getByAccountIdentifier(uuid).ifPresent(account -> { - - final List transactWriteItems = new ArrayList<>(List.of( - buildDelete(phoneNumberConstraintTableName, ATTR_ACCOUNT_E164, account.getNumber()), - buildDelete(accountsTableName, KEY_ACCOUNT_UUID, uuid), - buildDelete(phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, account.getPhoneNumberIdentifier()) - )); - - account.getUsernameHash().ifPresent(usernameHash -> transactWriteItems.add( - buildDelete(usernamesConstraintTableName, ATTR_USERNAME_HASH, usernameHash))); - - final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder() - .transactItems(transactWriteItems).build(); - db().transactWriteItems(request); - })); - } - - @Nonnull - public AccountCrawlChunk getAllFrom(final UUID from, final int maxCount) { - final ScanRequest.Builder scanRequestBuilder = ScanRequest.builder() - .limit(scanPageSize) - .exclusiveStartKey(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(from))); - - return scanForChunk(scanRequestBuilder, maxCount, GET_ALL_FROM_OFFSET_TIMER); - } - - @Nonnull - public AccountCrawlChunk getAllFromStart(final int maxCount) { - final ScanRequest.Builder scanRequestBuilder = ScanRequest.builder() - .limit(scanPageSize); - - return scanForChunk(scanRequestBuilder, maxCount, GET_ALL_FROM_START_TIMER); - } - - @Nonnull - private Optional getByIndirectLookup( - final Timer timer, - final String tableName, - final String keyName, - final AttributeValue keyValue) { - return getByIndirectLookup(timer, tableName, keyName, keyValue, i -> true); - } - - @Nonnull - private Optional getByIndirectLookup( - final Timer timer, - final String tableName, - final String keyName, - final AttributeValue keyValue, - final Predicate> predicate) { - - return requireNonNull(timer.record(() -> itemByKey(tableName, keyName, keyValue) - .filter(predicate) - .map(item -> item.get(KEY_ACCOUNT_UUID)) - .flatMap(uuid -> itemByKey(accountsTableName, KEY_ACCOUNT_UUID, uuid)) - .map(Accounts::fromItem))); - } - - @Nonnull - private Optional> itemByKey(final String table, final String keyName, final AttributeValue keyValue) { - final GetItemResponse response = db().getItem(GetItemRequest.builder() - .tableName(table) - .key(Map.of(keyName, keyValue)) - .consistentRead(true) - .build()); - return Optional.ofNullable(response.item()).filter(m -> !m.isEmpty()); - } - - @Nonnull - private TransactWriteItem buildAccountPut( - final Account account, - final AttributeValue uuidAttr, - final AttributeValue numberAttr, - final AttributeValue pniUuidAttr) throws JsonProcessingException { - - final Map item = new HashMap<>(Map.of( - KEY_ACCOUNT_UUID, uuidAttr, - ATTR_ACCOUNT_E164, numberAttr, - ATTR_PNI_UUID, pniUuidAttr, - ATTR_ACCOUNT_DATA, AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)), - ATTR_VERSION, AttributeValues.fromInt(account.getVersion()), - ATTR_CANONICALLY_DISCOVERABLE, AttributeValues.fromBool(account.shouldBeVisibleInDirectory()))); - - // Add the UAK if it's in the account - account.getUnidentifiedAccessKey() - .map(AttributeValues::fromByteArray) - .ifPresent(uak -> item.put(ATTR_UAK, uak)); - - return TransactWriteItem.builder() - .put(Put.builder() - .conditionExpression("attribute_not_exists(#number) OR #number = :number") - .expressionAttributeNames(Map.of("#number", ATTR_ACCOUNT_E164)) - .expressionAttributeValues(Map.of(":number", numberAttr)) - .tableName(accountsTableName) - .item(item) - .build()) - .build(); - } - - @Nonnull - private static TransactWriteItem buildConstraintTablePutIfAbsent( - final String tableName, - final AttributeValue uuidAttr, - final String keyName, - final AttributeValue keyValue - ) { - return TransactWriteItem.builder() - .put(Put.builder() - .tableName(tableName) - .item(Map.of( - keyName, keyValue, - KEY_ACCOUNT_UUID, uuidAttr)) - .conditionExpression( - "attribute_not_exists(#key) OR #uuid = :uuid") - .expressionAttributeNames(Map.of( - "#key", keyName, - "#uuid", KEY_ACCOUNT_UUID)) - .expressionAttributeValues(Map.of( - ":uuid", uuidAttr)) - .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD) - .build()) - .build(); - } - - @Nonnull - private static TransactWriteItem buildConstraintTablePut( - final String tableName, - final AttributeValue uuidAttr, - final String keyName, - final AttributeValue keyValue) { - return TransactWriteItem.builder() - .put(Put.builder() - .tableName(tableName) - .item(Map.of( - keyName, keyValue, - KEY_ACCOUNT_UUID, uuidAttr)) - .conditionExpression( - "attribute_not_exists(#key)") - .expressionAttributeNames(Map.of( - "#key", keyName)) - .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD) - .build()) - .build(); - } - - @Nonnull - private static TransactWriteItem buildDelete(final String tableName, final String keyName, final String keyValue) { - return buildDelete(tableName, keyName, AttributeValues.fromString(keyValue)); - } - - @Nonnull - private static TransactWriteItem buildDelete(final String tableName, final String keyName, final byte[] keyValue) { - return buildDelete(tableName, keyName, AttributeValues.fromByteArray(keyValue)); - } - - @Nonnull - private static TransactWriteItem buildDelete(final String tableName, final String keyName, final UUID keyValue) { - return buildDelete(tableName, keyName, AttributeValues.fromUUID(keyValue)); - } - - @Nonnull - private static TransactWriteItem buildDelete(final String tableName, final String keyName, final AttributeValue keyValue) { - return TransactWriteItem.builder() - .delete(Delete.builder() - .tableName(tableName) - .key(Map.of(keyName, keyValue)) - .build()) - .build(); - } - - @Nonnull - private static CompletionStage record(final Timer timer, final Supplier> toRecord) { - final Timer.Sample sample = Timer.start(); - return toRecord.get().whenComplete((ignoreT, ignoreE) -> sample.stop(timer)); - } - - @Nonnull - private AccountCrawlChunk scanForChunk(final ScanRequest.Builder scanRequestBuilder, final int maxCount, final Timer timer) { - scanRequestBuilder.tableName(accountsTableName); - final List> items = requireNonNull(timer.record(() -> scan(scanRequestBuilder.build(), maxCount))); - final List accounts = items.stream().map(Accounts::fromItem).toList(); - return new AccountCrawlChunk(accounts, accounts.size() > 0 ? accounts.get(accounts.size() - 1).getUuid() : null); - } - - @Nonnull - private static String extractCancellationReasonCodes(final TransactionCanceledException exception) { - return exception.cancellationReasons().stream() - .map(CancellationReason::code) - .collect(Collectors.joining(", ")); - } - - @VisibleForTesting - @Nonnull - static Account fromItem(final Map item) { - // TODO: eventually require ATTR_CANONICALLY_DISCOVERABLE - if (!item.containsKey(ATTR_ACCOUNT_DATA) - || !item.containsKey(ATTR_ACCOUNT_E164) - || !item.containsKey(KEY_ACCOUNT_UUID)) { - throw new RuntimeException("item missing values"); - } - try { - final Account account = SystemMapper.getMapper().readValue(item.get(ATTR_ACCOUNT_DATA).b().asByteArray(), Account.class); - - final UUID accountIdentifier = UUIDUtil.fromByteBuffer(item.get(KEY_ACCOUNT_UUID).b().asByteBuffer()); - final UUID phoneNumberIdentifierFromAttribute = AttributeValues.getUUID(item, ATTR_PNI_UUID, null); - - if (account.getPhoneNumberIdentifier() == null || phoneNumberIdentifierFromAttribute == null || - !Objects.equals(account.getPhoneNumberIdentifier(), phoneNumberIdentifierFromAttribute)) { - - log.warn("Missing or mismatched PNIs for account {}. From JSON: {}; from attribute: {}", - accountIdentifier, account.getPhoneNumberIdentifier(), phoneNumberIdentifierFromAttribute); - } - - account.setNumber(item.get(ATTR_ACCOUNT_E164).s(), phoneNumberIdentifierFromAttribute); - account.setUuid(accountIdentifier); - account.setUsernameHash(AttributeValues.getByteArray(item, ATTR_USERNAME_HASH, null)); - account.setVersion(Integer.parseInt(item.get(ATTR_VERSION).n())); - account.setCanonicallyDiscoverable(Optional.ofNullable(item.get(ATTR_CANONICALLY_DISCOVERABLE)) - .map(AttributeValue::bool) - .orElse(false)); - - return account; - - } catch (final IOException e) { - throw new RuntimeException("Could not read stored account data", e); - } - } - - private static boolean conditionalCheckFailed(final CancellationReason reason) { - return CONDITIONAL_CHECK_FAILED.equals(reason.code()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java deleted file mode 100644 index ea1c3def3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java +++ /dev/null @@ -1,774 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - - -import static com.codahale.metrics.MetricRegistry.name; -import static java.util.Objects.requireNonNull; - -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import io.lettuce.core.RedisException; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import java.io.IOException; -import java.time.Clock; -import java.time.Duration; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.redis.RedisOperation; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.util.Util; - -public class AccountsManager { - - private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private static final Timer createTimer = metricRegistry.timer(name(AccountsManager.class, "create")); - private static final Timer updateTimer = metricRegistry.timer(name(AccountsManager.class, "update")); - private static final Timer getByNumberTimer = metricRegistry.timer(name(AccountsManager.class, "getByNumber")); - private static final Timer getByUsernameHashTimer = metricRegistry.timer(name(AccountsManager.class, "getByUsernameHash")); - private static final Timer getByUuidTimer = metricRegistry.timer(name(AccountsManager.class, "getByUuid")); - private static final Timer deleteTimer = metricRegistry.timer(name(AccountsManager.class, "delete")); - - private static final Timer redisSetTimer = metricRegistry.timer(name(AccountsManager.class, "redisSet")); - private static final Timer redisNumberGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisNumberGet")); - private static final Timer redisUsernameHashGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUsernameHashGet")); - private static final Timer redisPniGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisPniGet")); - private static final Timer redisUuidGetTimer = metricRegistry.timer(name(AccountsManager.class, "redisUuidGet")); - private static final Timer redisDeleteTimer = metricRegistry.timer(name(AccountsManager.class, "redisDelete")); - - private static final String CREATE_COUNTER_NAME = name(AccountsManager.class, "createCounter"); - private static final String DELETE_COUNTER_NAME = name(AccountsManager.class, "deleteCounter"); - private static final String COUNTRY_CODE_TAG_NAME = "country"; - private static final String DELETION_REASON_TAG_NAME = "reason"; - - @VisibleForTesting - public static final String USERNAME_EXPERIMENT_NAME = "usernames"; - - private final Logger logger = LoggerFactory.getLogger(AccountsManager.class); - - private final Accounts accounts; - private final PhoneNumberIdentifiers phoneNumberIdentifiers; - private final FaultTolerantRedisCluster cacheCluster; - private final DeletedAccountsManager deletedAccountsManager; - private final DirectoryQueue directoryQueue; - private final Keys keys; - private final MessagesManager messagesManager; - private final ProfilesManager profilesManager; - private final StoredVerificationCodeManager pendingAccounts; - private final SecureStorageClient secureStorageClient; - private final SecureBackupClient secureBackupClient; - private final ClientPresenceManager clientPresenceManager; - private final ExperimentEnrollmentManager experimentEnrollmentManager; - private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager; - private final Clock clock; - - private static final ObjectMapper mapper = SystemMapper.getMapper(); - - // An account that's used at least daily will get reset in the cache at least once per day when its "last seen" - // timestamp updates; expiring entries after two days will help clear out "zombie" cache entries that are read - // frequently (e.g. the account is in an active group and receives messages frequently), but aren't actively used by - // the owner. - private static final long CACHE_TTL_SECONDS = Duration.ofDays(2).toSeconds(); - - private static final Duration USERNAME_HASH_RESERVATION_TTL_MINUTES = Duration.ofMinutes(5); - - @FunctionalInterface - private interface AccountPersister { - void persistAccount(Account account) throws UsernameHashNotAvailableException; - } - - public enum DeletionReason { - ADMIN_DELETED("admin"), - EXPIRED ("expired"), - USER_REQUEST ("userRequest"); - - private final String tagValue; - - DeletionReason(final String tagValue) { - this.tagValue = tagValue; - } - } - - public AccountsManager(final Accounts accounts, - final PhoneNumberIdentifiers phoneNumberIdentifiers, - final FaultTolerantRedisCluster cacheCluster, - final DeletedAccountsManager deletedAccountsManager, - final DirectoryQueue directoryQueue, - final Keys keys, - final MessagesManager messagesManager, - final ProfilesManager profilesManager, - final StoredVerificationCodeManager pendingAccounts, - final SecureStorageClient secureStorageClient, - final SecureBackupClient secureBackupClient, - final ClientPresenceManager clientPresenceManager, - final ExperimentEnrollmentManager experimentEnrollmentManager, - final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager, - final Clock clock) { - this.accounts = accounts; - this.phoneNumberIdentifiers = phoneNumberIdentifiers; - this.cacheCluster = cacheCluster; - this.deletedAccountsManager = deletedAccountsManager; - this.directoryQueue = directoryQueue; - this.keys = keys; - this.messagesManager = messagesManager; - this.profilesManager = profilesManager; - this.pendingAccounts = pendingAccounts; - this.secureStorageClient = secureStorageClient; - this.secureBackupClient = secureBackupClient; - this.clientPresenceManager = clientPresenceManager; - this.experimentEnrollmentManager = experimentEnrollmentManager; - this.registrationRecoveryPasswordsManager = requireNonNull(registrationRecoveryPasswordsManager); - this.clock = requireNonNull(clock); - } - - public Account create(final String number, - final String password, - final String signalAgent, - final AccountAttributes accountAttributes, - final List accountBadges) throws InterruptedException { - - try (Timer.Context ignored = createTimer.time()) { - final Account account = new Account(); - - deletedAccountsManager.lockAndTake(number, maybeRecentlyDeletedUuid -> { - Device device = new Device(); - device.setId(Device.MASTER_ID); - device.setAuthTokenHash(SaltedTokenHash.generateFor(password)); - device.setFetchesMessages(accountAttributes.getFetchesMessages()); - device.setRegistrationId(accountAttributes.getRegistrationId()); - accountAttributes.getPhoneNumberIdentityRegistrationId().ifPresent(device::setPhoneNumberIdentityRegistrationId); - device.setName(accountAttributes.getName()); - device.setCapabilities(accountAttributes.getCapabilities()); - device.setCreated(System.currentTimeMillis()); - device.setLastSeen(Util.todayInMillis()); - device.setUserAgent(signalAgent); - - account.setNumber(number, phoneNumberIdentifiers.getPhoneNumberIdentifier(number)); - account.setUuid(maybeRecentlyDeletedUuid.orElseGet(UUID::randomUUID)); - account.addDevice(device); - account.setRegistrationLockFromAttributes(accountAttributes); - account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey()); - account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess()); - account.setDiscoverableByPhoneNumber(accountAttributes.isDiscoverableByPhoneNumber()); - account.setBadges(clock, accountBadges); - - final UUID originalUuid = account.getUuid(); - - boolean freshUser = accounts.create(account); - - // create() sometimes updates the UUID, if there was a number conflict. - // for metrics, we want secondary to run with the same original UUID - final UUID actualUuid = account.getUuid(); - - redisSet(account); - - pendingAccounts.remove(number); - - // In terms of previously-existing accounts, there are three possible cases: - // - // 1. This is a completely new account; there was no pre-existing account and no recently-deleted account - // 2. This is a re-registration of an existing account. The storage layer will update the existing account in - // place to match the account record created above, and will update the UUID of the newly-created account - // instance to match the stored account record (i.e. originalUuid != actualUuid). - // 3. This is a re-registration of a recently-deleted account, in which case maybeRecentlyDeletedUuid is - // present. - // - // All cases are mutually-exclusive. In the first case, we don't need to do anything. In the third, we can be - // confident that everything has already been deleted. In the second case, though, we're taking over an existing - // account and need to clear out messages and keys that may have been stored for the old account. - if (!originalUuid.equals(actualUuid)) { - messagesManager.clear(actualUuid); - keys.delete(actualUuid); - keys.delete(account.getPhoneNumberIdentifier()); - profilesManager.deleteAll(actualUuid); - } - - final Tags tags; - - if (freshUser) { - tags = Tags.of("type", "new"); - } else if (!originalUuid.equals(actualUuid)) { - tags = Tags.of("type", "re-registration"); - } else { - tags = Tags.of("type", "recently-deleted"); - } - - Metrics.counter(CREATE_COUNTER_NAME, tags).increment(); - - if (!account.isDiscoverableByPhoneNumber()) { - // The newly-created account has explicitly opted out of discoverability - directoryQueue.deleteAccount(account); - } - - accountAttributes.recoveryPassword().ifPresent(registrationRecoveryPassword -> - registrationRecoveryPasswordsManager.storeForCurrentNumber(account.getNumber(), registrationRecoveryPassword)); - }); - - return account; - } - } - - public Account changeNumber(final Account account, final String number, - @Nullable final String pniIdentityKey, - @Nullable final Map pniSignedPreKeys, - @Nullable final Map pniRegistrationIds) throws InterruptedException, MismatchedDevicesException { - - final String originalNumber = account.getNumber(); - final UUID originalPhoneNumberIdentifier = account.getPhoneNumberIdentifier(); - - if (originalNumber.equals(number)) { - return account; - } - - if (pniSignedPreKeys != null && pniRegistrationIds != null) { - // Check that all including master ID are in signed pre-keys - DestinationDeviceValidator.validateCompleteDeviceList( - account, - pniSignedPreKeys.keySet(), - Collections.emptySet()); - - // Check that all devices are accounted for in the map of new PNI registration IDs - DestinationDeviceValidator.validateCompleteDeviceList( - account, - pniRegistrationIds.keySet(), - Collections.emptySet()); - } else if (pniSignedPreKeys != null || pniRegistrationIds != null) { - throw new IllegalArgumentException("Signed pre-keys and registration IDs must both be null or both be non-null"); - } - - final AtomicReference updatedAccount = new AtomicReference<>(); - - deletedAccountsManager.lockAndPut(account.getNumber(), number, (originalAci, deletedAci) -> { - redisDelete(account); - - final Optional maybeExistingAccount = getByE164(number); - final Optional displacedUuid; - - if (maybeExistingAccount.isPresent()) { - delete(maybeExistingAccount.get()); - directoryQueue.deleteAccount(maybeExistingAccount.get()); - displacedUuid = maybeExistingAccount.map(Account::getUuid); - } else { - displacedUuid = deletedAci; - } - - final UUID uuid = account.getUuid(); - final UUID phoneNumberIdentifier = phoneNumberIdentifiers.getPhoneNumberIdentifier(number); - - final Account numberChangedAccount; - - numberChangedAccount = updateWithRetries( - account, - a -> { - //noinspection ConstantConditions - if (pniSignedPreKeys != null && pniRegistrationIds != null) { - pniSignedPreKeys.forEach((deviceId, signedPreKey) -> - a.getDevice(deviceId).ifPresent(device -> device.setPhoneNumberIdentitySignedPreKey(signedPreKey))); - - pniRegistrationIds.forEach((deviceId, registrationId) -> - a.getDevice(deviceId).ifPresent(device -> device.setPhoneNumberIdentityRegistrationId(registrationId))); - } - - if (pniIdentityKey != null) { - a.setPhoneNumberIdentityKey(pniIdentityKey); - } - - return true; - }, - a -> accounts.changeNumber(a, number, phoneNumberIdentifier), - () -> accounts.getByAccountIdentifier(uuid).orElseThrow(), - AccountChangeValidator.NUMBER_CHANGE_VALIDATOR); - - updatedAccount.set(numberChangedAccount); - directoryQueue.changePhoneNumber(numberChangedAccount, originalNumber, number); - - keys.delete(phoneNumberIdentifier); - keys.delete(originalPhoneNumberIdentifier); - - return displacedUuid; - }); - - return updatedAccount.get(); - } - - public record UsernameReservation(Account account, byte[] reservedUsernameHash){} - - /** - * Reserve a username hash so that no other accounts may take it. - * - * The reserved hash can later be set with {@link #confirmReservedUsernameHash(Account, byte[])}. The reservation - * will eventually expire, after which point confirmReservedUsernameHash may fail if another account has taken the - * username hash. - * - * @param account the account to update - * @param requestedUsernameHashes the list of username hashes to attempt to reserve - * @return the reserved username hash and an updated Account object - * @throws UsernameHashNotAvailableException no username hash is available - */ - public UsernameReservation reserveUsernameHash(final Account account, final List requestedUsernameHashes) throws UsernameHashNotAvailableException { - if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) { - throw new UsernameHashNotAvailableException(); - } - - redisDelete(account); - - class Reserver implements AccountPersister { - byte[] reservedUsernameHash; - - @Override - public void persistAccount(final Account account) throws UsernameHashNotAvailableException { - for (byte[] usernameHash : requestedUsernameHashes) { - if (accounts.usernameHashAvailable(usernameHash)) { - reservedUsernameHash = usernameHash; - accounts.reserveUsernameHash( - account, - usernameHash, - USERNAME_HASH_RESERVATION_TTL_MINUTES); - return; - } - } - throw new UsernameHashNotAvailableException(); - } - } - final Reserver reserver = new Reserver(); - final Account updatedAccount = failableUpdateWithRetries( - account, - a -> true, - reserver, - () -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(), - AccountChangeValidator.USERNAME_CHANGE_VALIDATOR); - return new UsernameReservation(updatedAccount, reserver.reservedUsernameHash); - } - - /** - * Set a username hash previously reserved with {@link #reserveUsernameHash(Account, List)} - * - * @param account the account to update - * @param reservedUsernameHash the previously reserved username hash - * @return the updated account with the username hash field set - * @throws UsernameHashNotAvailableException if the reserved username hash has been taken (because the reservation expired) - * @throws UsernameReservationNotFoundException if `reservedUsernameHash` was not reserved in the account - */ - public Account confirmReservedUsernameHash(final Account account, final byte[] reservedUsernameHash) throws UsernameHashNotAvailableException, UsernameReservationNotFoundException { - if (!experimentEnrollmentManager.isEnrolled(account.getUuid(), USERNAME_EXPERIMENT_NAME)) { - throw new UsernameHashNotAvailableException(); - } - if (account.getUsernameHash().map(currentUsernameHash -> Arrays.equals(currentUsernameHash, reservedUsernameHash)).orElse(false)) { - // the client likely already succeeded and is retrying - return account; - } - - if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, reservedUsernameHash)).orElse(false)) { - // no such reservation existed, either there was no previous call to reserveUsername - // or the reservation changed - throw new UsernameReservationNotFoundException(); - } - - redisDelete(account); - - return failableUpdateWithRetries( - account, - a -> true, - a -> { - // though we know this username hash was reserved, the reservation could have lapsed - if (!accounts.usernameHashAvailable(Optional.of(account.getUuid()), reservedUsernameHash)) { - throw new UsernameHashNotAvailableException(); - } - accounts.confirmUsernameHash(a, reservedUsernameHash); - }, - () -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(), - AccountChangeValidator.USERNAME_CHANGE_VALIDATOR); - } - - public Account clearUsernameHash(final Account account) { - redisDelete(account); - - return updateWithRetries( - account, - a -> true, - accounts::clearUsernameHash, - () -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(), - AccountChangeValidator.USERNAME_CHANGE_VALIDATOR); - } - - public Account update(Account account, Consumer updater) { - return update(account, a -> { - updater.accept(a); - // assume that all updaters passed to the public method actually modify the account - return true; - }); - } - - /** - * Specialized version of {@link #updateDevice(Account, long, Consumer)} that minimizes potentially contentious and - * redundant updates of {@code device.lastSeen} - */ - public Account updateDeviceLastSeen(Account account, Device device, final long lastSeen) { - return update(account, a -> { - - final Optional maybeDevice = a.getDevice(device.getId()); - - return maybeDevice.map(d -> { - if (d.getLastSeen() >= lastSeen) { - return false; - } - - d.setLastSeen(lastSeen); - - return true; - - }).orElse(false); - }); - } - - public Account updateDeviceAuthentication(final Account account, final Device device, final SaltedTokenHash credentials) { - Preconditions.checkArgument(credentials.getVersion() == SaltedTokenHash.CURRENT_VERSION); - return updateDevice(account, device.getId(), device1 -> device1.setAuthTokenHash(credentials)); - } - - /** - * @param account account to update - * @param updater must return {@code true} if the account was actually updated - */ - private Account update(Account account, Function updater) { - - final boolean wasVisibleBeforeUpdate = account.shouldBeVisibleInDirectory(); - - final Account updatedAccount; - - try (Timer.Context ignored = updateTimer.time()) { - - redisDelete(account); - - final UUID uuid = account.getUuid(); - - updatedAccount = updateWithRetries(account, - updater, - accounts::update, - () -> accounts.getByAccountIdentifier(uuid).orElseThrow(), - AccountChangeValidator.GENERAL_CHANGE_VALIDATOR); - - redisSet(updatedAccount); - } - - final boolean isVisibleAfterUpdate = updatedAccount.shouldBeVisibleInDirectory(); - - if (wasVisibleBeforeUpdate != isVisibleAfterUpdate) { - directoryQueue.refreshAccount(updatedAccount); - } - - return updatedAccount; - } - - private Account updateWithRetries(Account account, - final Function updater, - final Consumer persister, - final Supplier retriever, - final AccountChangeValidator changeValidator) { - try { - return failableUpdateWithRetries(account, updater, persister::accept, retriever, changeValidator); - } catch (UsernameHashNotAvailableException e) { - // not possible - throw new IllegalStateException(e); - } - } - - private Account failableUpdateWithRetries(Account account, - final Function updater, - final AccountPersister persister, - final Supplier retriever, - final AccountChangeValidator changeValidator) throws UsernameHashNotAvailableException { - - Account originalAccount = cloneAccount(account); - - if (!updater.apply(account)) { - return account; - } - - final int maxTries = 10; - int tries = 0; - - while (tries < maxTries) { - - try { - persister.persistAccount(account); - - final Account updatedAccount = cloneAccount(account); - account.markStale(); - - changeValidator.validateChange(originalAccount, updatedAccount); - - return updatedAccount; - } catch (final ContestedOptimisticLockException e) { - tries++; - - account = retriever.get(); - originalAccount = cloneAccount(account); - - if (!updater.apply(account)) { - return account; - } - } - } - - throw new OptimisticLockRetryLimitExceededException(); - } - - private static Account cloneAccount(final Account account) { - try { - final Account clone = mapper.readValue(mapper.writeValueAsBytes(account), Account.class); - clone.setUuid(account.getUuid()); - - return clone; - } catch (final IOException e) { - // this should really, truly, never happen - throw new IllegalArgumentException(e); - } - } - - public Account updateDevice(Account account, long deviceId, Consumer deviceUpdater) { - return update(account, a -> { - a.getDevice(deviceId).ifPresent(deviceUpdater); - // assume that all updaters passed to the public method actually modify the device - return true; - }); - } - - public Optional getByE164(String number) { - try (Timer.Context ignored = getByNumberTimer.time()) { - Optional account = redisGetByE164(number); - - if (account.isEmpty()) { - account = accounts.getByE164(number); - account.ifPresent(this::redisSet); - } - - return account; - } - } - - public Optional getByPhoneNumberIdentifier(UUID pni) { - try (Timer.Context ignored = getByNumberTimer.time()) { - Optional account = redisGetByPhoneNumberIdentifier(pni); - - if (account.isEmpty()) { - account = accounts.getByPhoneNumberIdentifier(pni); - account.ifPresent(this::redisSet); - } - - return account; - } - } - - public Optional getByUsernameHash(final byte[] usernameHash) { - try (final Timer.Context ignored = getByUsernameHashTimer.time()) { - Optional account = redisGetByUsernameHash(usernameHash); - if (account.isEmpty()) { - account = accounts.getByUsernameHash(usernameHash); - account.ifPresent(this::redisSet); - } - - return account; - } - } - - public Optional getByAccountIdentifier(UUID uuid) { - try (Timer.Context ignored = getByUuidTimer.time()) { - Optional account = redisGetByAccountIdentifier(uuid); - - if (account.isEmpty()) { - account = accounts.getByAccountIdentifier(uuid); - account.ifPresent(this::redisSet); - } - - return account; - } - } - - public Optional getNumberForPhoneNumberIdentifier(UUID pni) { - return phoneNumberIdentifiers.getPhoneNumber(pni); - } - - public UUID getPhoneNumberIdentifier(String e164) { - return phoneNumberIdentifiers.getPhoneNumberIdentifier(e164); - } - - public AccountCrawlChunk getAllFromDynamo(int length) { - return accounts.getAllFromStart(length); - } - - public AccountCrawlChunk getAllFromDynamo(UUID uuid, int length) { - return accounts.getAllFrom(uuid, length); - } - - public void delete(final Account account, final DeletionReason deletionReason) throws InterruptedException { - try (final Timer.Context ignored = deleteTimer.time()) { - deletedAccountsManager.lockAndPut(account.getNumber(), () -> { - delete(account); - directoryQueue.deleteAccount(account); - - return account.getUuid(); - }); - } catch (final RuntimeException | InterruptedException e) { - logger.warn("Failed to delete account", e); - throw e; - } - - Metrics.counter(DELETE_COUNTER_NAME, - COUNTRY_CODE_TAG_NAME, Util.getCountryCode(account.getNumber()), - DELETION_REASON_TAG_NAME, deletionReason.tagValue) - .increment(); - } - - private void delete(final Account account) { - final CompletableFuture deleteStorageServiceDataFuture = secureStorageClient.deleteStoredData(account.getUuid()); - final CompletableFuture deleteBackupServiceDataFuture = secureBackupClient.deleteBackups(account.getUuid()); - - profilesManager.deleteAll(account.getUuid()); - keys.delete(account.getUuid()); - keys.delete(account.getPhoneNumberIdentifier()); - messagesManager.clear(account.getUuid()); - messagesManager.clear(account.getPhoneNumberIdentifier()); - registrationRecoveryPasswordsManager.removeForNumber(account.getNumber()); - - deleteStorageServiceDataFuture.join(); - deleteBackupServiceDataFuture.join(); - - accounts.delete(account.getUuid()); - redisDelete(account); - - RedisOperation.unchecked(() -> - account.getDevices().forEach(device -> - clientPresenceManager.disconnectPresence(account.getUuid(), device.getId()))); - } - - private String getUsernameHashAccountMapKey(byte[] usernameHash) { - return "UAccountMap::" + Base64.getUrlEncoder().withoutPadding().encodeToString(usernameHash); - } - - private String getAccountMapKey(String key) { - return "AccountMap::" + key; - } - - private String getAccountEntityKey(UUID uuid) { - return "Account3::" + uuid.toString(); - } - - private void redisSet(Account account) { - try (Timer.Context ignored = redisSetTimer.time()) { - final String accountJson = mapper.writeValueAsString(account); - - cacheCluster.useCluster(connection -> { - final RedisAdvancedClusterCommands commands = connection.sync(); - - commands.setex(getAccountMapKey(account.getPhoneNumberIdentifier().toString()), CACHE_TTL_SECONDS, account.getUuid().toString()); - commands.setex(getAccountMapKey(account.getNumber()), CACHE_TTL_SECONDS, account.getUuid().toString()); - commands.setex(getAccountEntityKey(account.getUuid()), CACHE_TTL_SECONDS, accountJson); - - account.getUsernameHash().ifPresent(usernameHash -> - commands.setex(getUsernameHashAccountMapKey(usernameHash), CACHE_TTL_SECONDS, account.getUuid().toString())); - }); - } catch (JsonProcessingException e) { - throw new IllegalStateException(e); - } - } - - private Optional redisGetByPhoneNumberIdentifier(UUID uuid) { - return redisGetBySecondaryKey(getAccountMapKey(uuid.toString()), redisPniGetTimer); - } - - private Optional redisGetByE164(String e164) { - return redisGetBySecondaryKey(getAccountMapKey(e164), redisNumberGetTimer); - } - - private Optional redisGetByUsernameHash(byte[] usernameHash) { - return redisGetBySecondaryKey(getUsernameHashAccountMapKey(usernameHash), redisUsernameHashGetTimer); - } - - private Optional redisGetBySecondaryKey(String secondaryKey, Timer timer) { - try (Timer.Context ignored = timer.time()) { - final String uuid = cacheCluster.withCluster(connection -> connection.sync().get(secondaryKey)); - - if (uuid != null) return redisGetByAccountIdentifier(UUID.fromString(uuid)); - else return Optional.empty(); - } catch (IllegalArgumentException e) { - logger.warn("Deserialization error", e); - return Optional.empty(); - } catch (RedisException e) { - logger.warn("Redis failure", e); - return Optional.empty(); - } - } - - private Optional redisGetByAccountIdentifier(UUID uuid) { - try (Timer.Context ignored = redisUuidGetTimer.time()) { - final String json = cacheCluster.withCluster(connection -> connection.sync().get(getAccountEntityKey(uuid))); - - if (json != null) { - Account account = mapper.readValue(json, Account.class); - account.setUuid(uuid); - - if (account.getPhoneNumberIdentifier() == null) { - logger.warn("Account {} loaded from Redis is missing a PNI", uuid); - } - - return Optional.of(account); - } - - return Optional.empty(); - } catch (IOException e) { - logger.warn("Deserialization error", e); - return Optional.empty(); - } catch (RedisException e) { - logger.warn("Redis failure", e); - return Optional.empty(); - } - } - - private void redisDelete(final Account account) { - try (final Timer.Context ignored = redisDeleteTimer.time()) { - cacheCluster.useCluster(connection -> { - connection.sync().del( - getAccountMapKey(account.getNumber()), - getAccountMapKey(account.getPhoneNumberIdentifier().toString()), - getAccountEntityKey(account.getUuid())); - - account.getUsernameHash().ifPresent(usernameHash -> connection.sync().del(getUsernameHashAccountMapKey(usernameHash))); - }); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManager.java deleted file mode 100644 index 410071fde..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManager.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import com.google.common.annotations.VisibleForTesting; -import com.google.protobuf.ByteString; -import org.apache.commons.lang3.ObjectUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.controllers.AccountController; -import org.whispersystems.textsecuregcm.controllers.MessageController; -import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException; -import org.whispersystems.textsecuregcm.controllers.StaleDevicesException; -import org.whispersystems.textsecuregcm.entities.IncomingMessage; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.push.MessageSender; -import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.util.DestinationDeviceValidator; -import javax.annotation.Nullable; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -public class ChangeNumberManager { - private static final Logger logger = LoggerFactory.getLogger(AccountController.class); - private final MessageSender messageSender; - private final AccountsManager accountsManager; - - public ChangeNumberManager( - final MessageSender messageSender, - final AccountsManager accountsManager) { - this.messageSender = messageSender; - this.accountsManager = accountsManager; - } - - public Account changeNumber(final Account account, final String number, - @Nullable final String pniIdentityKey, - @Nullable final Map deviceSignedPreKeys, - @Nullable final List deviceMessages, - @Nullable final Map pniRegistrationIds) - throws InterruptedException, MismatchedDevicesException, StaleDevicesException { - - if (ObjectUtils.allNotNull(pniIdentityKey, deviceSignedPreKeys, deviceMessages, pniRegistrationIds)) { - assert pniIdentityKey != null; - assert deviceSignedPreKeys != null; - assert deviceMessages != null; - assert pniRegistrationIds != null; - - // Check that all except master ID are in device messages - DestinationDeviceValidator.validateCompleteDeviceList( - account, - deviceMessages.stream().map(IncomingMessage::destinationDeviceId).collect(Collectors.toSet()), - Set.of(Device.MASTER_ID)); - - DestinationDeviceValidator.validateRegistrationIds( - account, - deviceMessages, - IncomingMessage::destinationDeviceId, - IncomingMessage::destinationRegistrationId, - false); - } else if (!ObjectUtils.allNull(pniIdentityKey, deviceSignedPreKeys, deviceMessages, pniRegistrationIds)) { - throw new IllegalArgumentException("PNI identity key, signed pre-keys, device messages, and registration IDs must be all null or all non-null"); - } - - final Account updatedAccount; - - if (number.equals(account.getNumber())) { - // This may be a request that got repeated due to poor network conditions or other client error; take no action, - // but report success since the account is in the desired state - updatedAccount = account; - } else { - updatedAccount = accountsManager.changeNumber(account, number, pniIdentityKey, deviceSignedPreKeys, pniRegistrationIds); - } - - // Whether the account already has this number or not, we resend messages. This makes it so the client can resend a - // request they didn't get a response for (timeout, etc) to make sure their messages sent even if the first time - // around the server crashed at/above this point. - if (deviceMessages != null) { - deviceMessages.forEach(message -> - sendMessageToSelf(updatedAccount, updatedAccount.getDevice(message.destinationDeviceId()), message)); - } - - return updatedAccount; - } - - @VisibleForTesting - void sendMessageToSelf( - Account sourceAndDestinationAccount, Optional destinationDevice, IncomingMessage message) { - Optional contents = MessageController.getMessageContent(message); - if (contents.isEmpty()) { - logger.debug("empty message contents sending to self, ignoring"); - return; - } else if (destinationDevice.isEmpty()) { - logger.debug("destination device not present"); - return; - } - try { - long serverTimestamp = System.currentTimeMillis(); - Envelope envelope = Envelope.newBuilder() - .setType(Envelope.Type.forNumber(message.type())) - .setTimestamp(serverTimestamp) - .setServerTimestamp(serverTimestamp) - .setDestinationUuid(sourceAndDestinationAccount.getUuid().toString()) - .setContent(ByteString.copyFrom(contents.get())) - .setSourceUuid(sourceAndDestinationAccount.getUuid().toString()) - .setSourceDevice((int) Device.MASTER_ID) - .setUpdatedPni(sourceAndDestinationAccount.getPhoneNumberIdentifier().toString()) - .setUrgent(true) - .build(); - - messageSender.sendMessage(sourceAndDestinationAccount, destinationDevice.get(), envelope, false); - } catch (NotPushRegisteredException e) { - logger.debug("Not registered", e); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ChunkProcessingFailedException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ChunkProcessingFailedException.java deleted file mode 100644 index 759a963e1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ChunkProcessingFailedException.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -public class ChunkProcessingFailedException extends Exception { - - public ChunkProcessingFailedException(String message) { - super(message); - } - - public ChunkProcessingFailedException(Exception cause) { - super(cause); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ContactDiscoveryWriter.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ContactDiscoveryWriter.java deleted file mode 100644 index 3f5c1b57a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ContactDiscoveryWriter.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.google.common.annotations.VisibleForTesting; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Consumer; - -public class ContactDiscoveryWriter extends AccountDatabaseCrawlerListener { - - private final AccountsManager accounts; - - public ContactDiscoveryWriter(final AccountsManager accounts) { - this.accounts = accounts; - } - - @Override - public void onCrawlStart() { - // nothing - } - - @Override - public void onCrawlEnd(final Optional fromUuid) { - // nothing - } - - // We "update" by doing nothing, since everything about the account is already accurate except for a temporal - // change in the 'shouldBeVisible' trait. This update forces a new write of the underlying DB to reflect - // that temporal change persistently. - @VisibleForTesting - static final Consumer NOOP_UPDATER = a -> {}; - - @Override - protected void onCrawlChunk(final Optional fromUuid, final List chunkAccounts) - throws AccountDatabaseCrawlerRestartException { - for (Account account : chunkAccounts) { - if (account.isCanonicallyDiscoverable() != account.shouldBeVisibleInDirectory()) { - // It’s less than ideal, but crawler listeners currently must not call update() - // with the accounts from the chunk, because updates cause the account instance to become stale. Instead, they - // must get a new copy, which they are free to update. - accounts.getByAccountIdentifier(account.getUuid()).ifPresent(a -> accounts.update(a, NOOP_UPDATER)); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ContestedOptimisticLockException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ContestedOptimisticLockException.java deleted file mode 100644 index 7b961d89c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ContestedOptimisticLockException.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -public class ContestedOptimisticLockException extends RuntimeException { - - public ContestedOptimisticLockException() { - super(null, null, true, false); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccounts.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccounts.java deleted file mode 100644 index 72a0cf46f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccounts.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Queue; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.Pair; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.BatchGetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.BatchGetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.KeysAndAttributes; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryResponse; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -public class DeletedAccounts extends AbstractDynamoDbStore { - - // e164, primary key - static final String KEY_ACCOUNT_E164 = "P"; - static final String ATTR_ACCOUNT_UUID = "U"; - static final String ATTR_EXPIRES = "E"; - static final String ATTR_NEEDS_CDS_RECONCILIATION = "R"; - - static final String UUID_TO_E164_INDEX_NAME = "u_to_p"; - - static final Duration TIME_TO_LIVE = Duration.ofDays(30); - - // Note that this limit is imposed by DynamoDB itself; going above 100 will result in errors - static final int GET_BATCH_SIZE = 100; - - private final String tableName; - private final String needsReconciliationIndexName; - - public DeletedAccounts(final DynamoDbClient dynamoDb, final String tableName, final String needsReconciliationIndexName) { - - super(dynamoDb); - this.tableName = tableName; - this.needsReconciliationIndexName = needsReconciliationIndexName; - } - - void put(UUID uuid, String e164, boolean needsReconciliation) { - db().putItem(PutItemRequest.builder() - .tableName(tableName) - .item(Map.of( - KEY_ACCOUNT_E164, AttributeValues.fromString(e164), - ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(uuid), - ATTR_EXPIRES, AttributeValues.fromLong(Instant.now().plus(TIME_TO_LIVE).getEpochSecond()), - ATTR_NEEDS_CDS_RECONCILIATION, AttributeValues.fromInt(needsReconciliation ? 1 : 0))) - .build()); - } - - Optional findUuid(final String e164) { - final GetItemResponse response = db().getItem(GetItemRequest.builder() - .tableName(tableName) - .consistentRead(true) - .key(Map.of(KEY_ACCOUNT_E164, AttributeValues.fromString(e164))) - .build()); - - return Optional.ofNullable(AttributeValues.getUUID(response.item(), ATTR_ACCOUNT_UUID, null)); - } - - Optional findE164(final UUID uuid) { - final QueryResponse response = db().query(QueryRequest.builder() - .tableName(tableName) - .indexName(UUID_TO_E164_INDEX_NAME) - .keyConditionExpression("#uuid = :uuid") - .projectionExpression("#e164") - .expressionAttributeNames(Map.of("#uuid", ATTR_ACCOUNT_UUID, - "#e164", KEY_ACCOUNT_E164)) - .expressionAttributeValues(Map.of(":uuid", AttributeValues.fromUUID(uuid))).build()); - - if (response.count() == 0) { - return Optional.empty(); - } - - if (response.count() > 1) { - throw new RuntimeException( - "Impossible result: more than one phone number returned for UUID: " + uuid); - } - - return Optional.ofNullable(response.items().get(0).get(KEY_ACCOUNT_E164).s()); - } - - void remove(final String e164) { - db().deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_ACCOUNT_E164, AttributeValues.fromString(e164))) - .build()); - } - - List> listAccountsToReconcile(final int max) { - - final ScanRequest scanRequest = ScanRequest.builder() - .tableName(tableName) - .indexName(needsReconciliationIndexName) - .limit(max) - .build(); - - return scan(scanRequest, max) - .stream() - .map(item -> new Pair<>( - AttributeValues.getUUID(item, ATTR_ACCOUNT_UUID, null), - AttributeValues.getString(item, KEY_ACCOUNT_E164, null))) - .collect(Collectors.toList()); - } - - Set getAccountsNeedingReconciliation(final Collection e164s) { - final Queue> pendingKeys = e164s.stream() - .map(e164 -> Map.of(KEY_ACCOUNT_E164, AttributeValues.fromString(e164))) - .collect(Collectors.toCollection(() -> new ArrayDeque<>(e164s.size()))); - - final Set accountsNeedingReconciliation = new HashSet<>(e164s.size()); - final List> batchKeys = new ArrayList<>(GET_BATCH_SIZE); - - while (!pendingKeys.isEmpty()) { - batchKeys.clear(); - - for (int i = 0; i < GET_BATCH_SIZE && !pendingKeys.isEmpty(); i++) { - batchKeys.add(pendingKeys.remove()); - } - - final BatchGetItemResponse response = db().batchGetItem(BatchGetItemRequest.builder() - .requestItems(Map.of(tableName, KeysAndAttributes.builder() - .consistentRead(true) - .keys(batchKeys) - .build())) - .build()); - - response.responses().getOrDefault(tableName, Collections.emptyList()).stream() - .filter(attributes -> AttributeValues.getInt(attributes, ATTR_NEEDS_CDS_RECONCILIATION, 0) == 1) - .map(attributes -> AttributeValues.getString(attributes, KEY_ACCOUNT_E164, null)) - .forEach(accountsNeedingReconciliation::add); - - if (response.hasUnprocessedKeys() && response.unprocessedKeys().containsKey(tableName)) { - pendingKeys.addAll(response.unprocessedKeys().get(tableName).keys()); - } - } - - return accountsNeedingReconciliation; - } - - void markReconciled(final Collection phoneNumbersReconciled) { - - phoneNumbersReconciled.forEach(number -> db().updateItem( - UpdateItemRequest.builder() - .tableName(tableName) - .key(Map.of( - KEY_ACCOUNT_E164, AttributeValues.fromString(number) - )) - .updateExpression("REMOVE #needs_reconciliation") - .expressionAttributeNames(Map.of( - "#needs_reconciliation", ATTR_NEEDS_CDS_RECONCILIATION - )) - .build() - )); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsDirectoryReconciler.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsDirectoryReconciler.java deleted file mode 100644 index 944f41160..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsDirectoryReconciler.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest.User; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse; - -public class DeletedAccountsDirectoryReconciler { - - private final Logger logger = LoggerFactory.getLogger(DeletedAccountsDirectoryReconciler.class); - - private final DirectoryReconciliationClient directoryReconciliationClient; - - private final Timer deleteTimer; - private final Counter errorCounter; - - public DeletedAccountsDirectoryReconciler( - final String replicationName, - final DirectoryReconciliationClient directoryReconciliationClient) { - this.directoryReconciliationClient = directoryReconciliationClient; - - deleteTimer = Timer.builder(name(DeletedAccountsDirectoryReconciler.class, "delete")) - .tag("replicationName", replicationName) - .register(Metrics.globalRegistry); - - errorCounter = Counter.builder(name(DeletedAccountsDirectoryReconciler.class, "error")) - .tag("replicationName", replicationName) - .register(Metrics.globalRegistry); - } - - public void onCrawlChunk(final List deletedUsers) throws ChunkProcessingFailedException { - - try { - deleteTimer.recordCallable(() -> { - try { - final DirectoryReconciliationResponse response = directoryReconciliationClient.delete( - new DirectoryReconciliationRequest(deletedUsers)); - - if (response.getStatus() != DirectoryReconciliationResponse.Status.OK) { - errorCounter.increment(); - throw new ChunkProcessingFailedException("Response status: " + response.getStatus()); - } - } catch (final Exception e) { - errorCounter.increment(); - throw new ChunkProcessingFailedException(e); - } - - return null; - }); - } catch (final ChunkProcessingFailedException e) { - throw e; - } catch (final Exception e) { - logger.warn("Unexpected exception", e); - throw new RuntimeException(e); - } - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManager.java deleted file mode 100644 index fdb8ba7a8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManager.java +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.amazonaws.services.dynamodbv2.AcquireLockOptions; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBLockClient; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBLockClientOptions; -import com.amazonaws.services.dynamodbv2.LockItem; -import com.amazonaws.services.dynamodbv2.ReleaseLockOptions; -import com.amazonaws.services.dynamodbv2.model.LockCurrentlyUnavailableException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import java.util.function.BiFunction; -import java.util.function.Consumer; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Pair; - -public class DeletedAccountsManager { - - private final DeletedAccounts deletedAccounts; - - private final AmazonDynamoDBLockClient lockClient; - - private static final Logger log = LoggerFactory.getLogger(DeletedAccountsManager.class); - - @FunctionalInterface - public interface DeletedAccountReconciliationConsumer { - - /** - * Reconcile a list of deleted account records. - * - * @param deletedAccounts the account records to reconcile - * @return a list of account records that were successfully reconciled; accounts that were not successfully - * reconciled may be retried later - * @throws ChunkProcessingFailedException in the event of an error while processing the batch of account records - */ - Collection reconcile(List> deletedAccounts) throws ChunkProcessingFailedException; - } - - public DeletedAccountsManager(final DeletedAccounts deletedAccounts, final AmazonDynamoDB lockDynamoDb, final String lockTableName) { - this.deletedAccounts = deletedAccounts; - - lockClient = new AmazonDynamoDBLockClient( - AmazonDynamoDBLockClientOptions.builder(lockDynamoDb, lockTableName) - .withPartitionKeyName(DeletedAccounts.KEY_ACCOUNT_E164) - .withLeaseDuration(15L) - .withHeartbeatPeriod(2L) - .withTimeUnit(TimeUnit.SECONDS) - .withCreateHeartbeatBackgroundThread(true) - .build()); - } - - /** - * Acquires a pessimistic lock for the given phone number and performs the given action, passing the UUID of the - * recently-deleted account (if any) that previously held the given number. - * - * @param e164 the phone number to lock and with which to perform an action - * @param consumer the action to take; accepts the UUID of the account that previously held the given e164, if any, - * as an argument - * - * @throws InterruptedException if interrupted while waiting to acquire a lock on the given phone number - */ - public void lockAndTake(final String e164, final Consumer> consumer) throws InterruptedException { - withLock(List.of(e164), acis -> { - try { - consumer.accept(acis.get(0)); - deletedAccounts.remove(e164); - } catch (final Exception e) { - log.warn("Consumer threw an exception while holding lock on a deleted account record", e); - throw new RuntimeException(e); - } - }); - } - - /** - * Acquires a pessimistic lock for the given phone number and performs an action that deletes an account, returning - * the UUID of the deleted account. The UUID of the deleted account will be stored in the list of recently-deleted - * e164-to-UUID mappings. - * - * @param e164 the phone number to lock and with which to perform an action - * @param supplier the deletion action to take on the account associated with the given number; must return the UUID - * of the deleted account - * - * @throws InterruptedException if interrupted while waiting to acquire a lock on the given phone number - */ - public void lockAndPut(final String e164, final Supplier supplier) throws InterruptedException { - withLock(List.of(e164), ignored -> { - try { - deletedAccounts.put(supplier.get(), e164, true); - } catch (final Exception e) { - log.warn("Supplier threw an exception while holding lock on a deleted account record", e); - throw new RuntimeException(e); - } - }); - } - - /** - * Acquires a pessimistic lock for the given phone numbers and performs an action that may or may not delete an - * account associated with the target number. The UUID of the deleted account (if any) will be stored in the list of - * recently-deleted e164-to-UUID mappings. - * - * @param original the phone number of an existing account to lock and with which to perform an action - * @param target the phone number of an account that may or may not exist with which to perform an action - * @param function the action to take on the given phone numbers and ACIs, if known; the action may delete the account - * identified by the target number, in which case it must return the ACI of that account - * @throws InterruptedException if interrupted while waiting to acquire a lock on the given phone numbers - */ - public void lockAndPut(final String original, final String target, - final BiFunction, Optional, Optional> function) - throws InterruptedException { - - withLock(List.of(original, target), acis -> { - try { - function.apply(acis.get(0), acis.get(1)).ifPresent(aci -> deletedAccounts.put(aci, original, true)); - } catch (final Exception e) { - log.warn("Supplier threw an exception while holding lock on a deleted account record", e); - throw new RuntimeException(e); - } - }); - } - - private void withLock(final List e164s, final Consumer>> task) - throws InterruptedException { - final List lockItems = new ArrayList<>(e164s.size()); - - try { - final List> previouslyDeletedUuids = new ArrayList<>(e164s.size()); - for (final String e164 : e164s) { - lockItems.add(lockClient.acquireLock(AcquireLockOptions.builder(e164) - .withAcquireReleasedLocksConsistently(true) - .build())); - previouslyDeletedUuids.add(deletedAccounts.findUuid(e164)); - } - - task.accept(previouslyDeletedUuids); - } finally { - for (final LockItem lockItem : lockItems) { - lockClient.releaseLock(ReleaseLockOptions.builder(lockItem) - .withBestEffort(true) - .build()); - } - } - } - - public void lockAndReconcileAccounts(final int max, final DeletedAccountReconciliationConsumer consumer) throws ChunkProcessingFailedException { - final List lockItems = new ArrayList<>(); - final List> reconciliationCandidates = deletedAccounts.listAccountsToReconcile(max).stream() - .filter(pair -> { - boolean lockAcquired = false; - - try { - lockItems.add(lockClient.acquireLock(AcquireLockOptions.builder(pair.second()) - .withAcquireReleasedLocksConsistently(true) - .withShouldSkipBlockingWait(true) - .build())); - - lockAcquired = true; - } catch (final InterruptedException e) { - log.warn("Interrupted while acquiring lock for reconciliation", e); - } catch (final LockCurrentlyUnavailableException ignored) { - } - - return lockAcquired; - }).toList(); - - assert lockItems.size() == reconciliationCandidates.size(); - - // A deleted account's status may have changed in the time between getting a list of candidates and acquiring a lock - // on the candidate records. Now that we hold the lock, check which of the candidates still need to be reconciled. - final Set numbersNeedingReconciliationAfterLock = - deletedAccounts.getAccountsNeedingReconciliation(reconciliationCandidates.stream() - .map(Pair::second) - .collect(Collectors.toList())); - - final List> accountsToReconcile = reconciliationCandidates.stream() - .filter(candidate -> numbersNeedingReconciliationAfterLock.contains(candidate.second())) - .collect(Collectors.toList()); - - try { - deletedAccounts.markReconciled(consumer.reconcile(accountsToReconcile)); - } finally { - lockItems.forEach( - lockItem -> lockClient.releaseLock(ReleaseLockOptions.builder(lockItem).withBestEffort(true).build())); - } - } - - public Optional findDeletedAccountAci(final String e164) { - return deletedAccounts.findUuid(e164); - } - - public Optional findDeletedAccountE164(final UUID uuid) { - return deletedAccounts.findE164(uuid); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTableCrawler.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTableCrawler.java deleted file mode 100644 index c6e83b702..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTableCrawler.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import io.micrometer.core.instrument.Metrics; -import java.io.IOException; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.ScheduledExecutorService; -import java.util.stream.Collectors; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest.User; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.Pair; - -public class DeletedAccountsTableCrawler extends ManagedPeriodicWork { - - private static final Duration WORKER_TTL = Duration.ofMinutes(2); - private static final Duration RUN_INTERVAL = Duration.ofMinutes(15); - private static final String ACTIVE_WORKER_KEY = "deleted_accounts_crawler_cache_active_worker"; - - private static final int MAX_BATCH_SIZE = 5_000; - private static final String BATCH_SIZE_DISTRIBUTION_NAME = name(DeletedAccountsTableCrawler.class, "batchSize"); - - private final DeletedAccountsManager deletedAccountsManager; - private final List reconcilers; - - public DeletedAccountsTableCrawler( - final DeletedAccountsManager deletedAccountsManager, - final List reconcilers, - final FaultTolerantRedisCluster cluster, - final ScheduledExecutorService executorService) throws IOException { - - super(new ManagedPeriodicWorkLock(ACTIVE_WORKER_KEY, cluster), WORKER_TTL, RUN_INTERVAL, executorService); - - this.deletedAccountsManager = deletedAccountsManager; - this.reconcilers = reconcilers; - } - - @Override - public void doPeriodicWork() throws Exception { - - deletedAccountsManager.lockAndReconcileAccounts(MAX_BATCH_SIZE, deletedAccounts -> { - final List deletedUsers = deletedAccounts.stream() - .map(pair -> new User(pair.first(), pair.second())) - .collect(Collectors.toList()); - - for (DeletedAccountsDirectoryReconciler reconciler : reconcilers) { - reconciler.onCrawlChunk(deletedUsers); - } - - final List reconciledPhoneNumbers = deletedAccounts.stream() - .map(Pair::second) - .collect(Collectors.toList()); - - Metrics.summary(BATCH_SIZE_DISTRIBUTION_NAME).record(reconciledPhoneNumbers.size()); - - return reconciledPhoneNumbers; - }); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java deleted file mode 100644 index e0c8c9b10..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java +++ /dev/null @@ -1,336 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.OptionalInt; -import java.util.concurrent.TimeUnit; -import javax.annotation.Nullable; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.util.Util; - -public class Device { - - public static final long MASTER_ID = 1; - - @JsonProperty - private long id; - - @JsonProperty - private String name; - - @JsonProperty - private String authToken; - - @JsonProperty - private String salt; - - @JsonProperty - private String gcmId; - - @JsonProperty - private String apnId; - - @JsonProperty - private String voipApnId; - - @JsonProperty - private long pushTimestamp; - - @JsonProperty - private long uninstalledFeedback; - - @JsonProperty - private boolean fetchesMessages; - - @JsonProperty - private int registrationId; - - @Nullable - @JsonProperty("pniRegistrationId") - private Integer phoneNumberIdentityRegistrationId; - - @JsonProperty - private SignedPreKey signedPreKey; - - @JsonProperty("pniSignedPreKey") - private SignedPreKey phoneNumberIdentitySignedPreKey; - - @JsonProperty - private long lastSeen; - - @JsonProperty - private long created; - - @JsonProperty - private String userAgent; - - @JsonProperty - private DeviceCapabilities capabilities; - - public String getApnId() { - return apnId; - } - - public void setApnId(String apnId) { - this.apnId = apnId; - - if (apnId != null) { - this.pushTimestamp = System.currentTimeMillis(); - } - } - - public String getVoipApnId() { - return voipApnId; - } - - public void setVoipApnId(String voipApnId) { - this.voipApnId = voipApnId; - } - - public void setUninstalledFeedbackTimestamp(long uninstalledFeedback) { - this.uninstalledFeedback = uninstalledFeedback; - } - - public long getUninstalledFeedbackTimestamp() { - return uninstalledFeedback; - } - - public void setLastSeen(long lastSeen) { - this.lastSeen = lastSeen; - } - - public long getLastSeen() { - return lastSeen; - } - - public void setCreated(long created) { - this.created = created; - } - - public long getCreated() { - return this.created; - } - - public String getGcmId() { - return gcmId; - } - - public void setGcmId(String gcmId) { - this.gcmId = gcmId; - - if (gcmId != null) { - this.pushTimestamp = System.currentTimeMillis(); - } - } - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public void setAuthTokenHash(SaltedTokenHash credentials) { - this.authToken = credentials.hash(); - this.salt = credentials.salt(); - } - - /** - * Has this device been manually locked? - * - * We lock a device by prepending "!" to its token. - * This character cannot normally appear in valid tokens. - * - * @return true if the credential was locked, false otherwise. - */ - public boolean hasLockedCredentials() { - SaltedTokenHash auth = getAuthTokenHash(); - return auth.hash().startsWith("!"); - } - - /** - * Lock device by invalidating authentication tokens. - * - * This should only be used from Account::lockAuthenticationCredentials. - * - * See that method for more information. - */ - public void lockAuthTokenHash() { - SaltedTokenHash oldAuth = getAuthTokenHash(); - String token = "!" + oldAuth.hash(); - String salt = oldAuth.salt(); - setAuthTokenHash(new SaltedTokenHash(token, salt)); - } - - public SaltedTokenHash getAuthTokenHash() { - return new SaltedTokenHash(authToken, salt); - } - - @Nullable - public DeviceCapabilities getCapabilities() { - return capabilities; - } - - public void setCapabilities(DeviceCapabilities capabilities) { - this.capabilities = capabilities; - } - - public boolean isEnabled() { - boolean hasChannel = fetchesMessages || !Util.isEmpty(getApnId()) || !Util.isEmpty(getGcmId()); - - return (id == MASTER_ID && hasChannel && signedPreKey != null) || - (id != MASTER_ID && hasChannel && signedPreKey != null && lastSeen > (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30))); - } - - public boolean getFetchesMessages() { - return fetchesMessages; - } - - public void setFetchesMessages(boolean fetchesMessages) { - this.fetchesMessages = fetchesMessages; - } - - public boolean isMaster() { - return getId() == MASTER_ID; - } - - public int getRegistrationId() { - return registrationId; - } - - public void setRegistrationId(int registrationId) { - this.registrationId = registrationId; - } - - public OptionalInt getPhoneNumberIdentityRegistrationId() { - return phoneNumberIdentityRegistrationId != null ? OptionalInt.of(phoneNumberIdentityRegistrationId) : OptionalInt.empty(); - } - - public void setPhoneNumberIdentityRegistrationId(final int phoneNumberIdentityRegistrationId) { - this.phoneNumberIdentityRegistrationId = phoneNumberIdentityRegistrationId; - } - - public SignedPreKey getSignedPreKey() { - return signedPreKey; - } - - public void setSignedPreKey(SignedPreKey signedPreKey) { - this.signedPreKey = signedPreKey; - } - - public SignedPreKey getPhoneNumberIdentitySignedPreKey() { - return phoneNumberIdentitySignedPreKey; - } - - public void setPhoneNumberIdentitySignedPreKey(final SignedPreKey phoneNumberIdentitySignedPreKey) { - this.phoneNumberIdentitySignedPreKey = phoneNumberIdentitySignedPreKey; - } - - public long getPushTimestamp() { - return pushTimestamp; - } - - public void setUserAgent(String userAgent) { - this.userAgent = userAgent; - } - - public String getUserAgent() { - return this.userAgent; - } - - public static class DeviceCapabilities { - @JsonProperty - private boolean storage; - - @JsonProperty - private boolean transfer; - - @JsonProperty - private boolean senderKey; - - @JsonProperty - private boolean announcementGroup; - - @JsonProperty - private boolean changeNumber; - - @JsonProperty - private boolean pni; - - @JsonProperty - private boolean stories; - - @JsonProperty - private boolean giftBadges; - - @JsonProperty - private boolean paymentActivation; - - public DeviceCapabilities() { - } - - public DeviceCapabilities(boolean storage, boolean transfer, - final boolean senderKey, final boolean announcementGroup, final boolean changeNumber, - final boolean pni, final boolean stories, final boolean giftBadges, final boolean paymentActivation) { - this.storage = storage; - this.transfer = transfer; - this.senderKey = senderKey; - this.announcementGroup = announcementGroup; - this.changeNumber = changeNumber; - this.pni = pni; - this.stories = stories; - this.giftBadges = giftBadges; - this.paymentActivation = paymentActivation; - } - - public boolean isStorage() { - return storage; - } - - public boolean isTransfer() { - return transfer; - } - - public boolean isSenderKey() { - return senderKey; - } - - public boolean isAnnouncementGroup() { - return announcementGroup; - } - - public boolean isChangeNumber() { - return changeNumber; - } - - public boolean isPni() { - return pni; - } - - public boolean isStories() { - return stories; - } - - public boolean isGiftBadges() { - return giftBadges; - } - - public boolean isPaymentActivation() { - return paymentActivation; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DirectoryReconciler.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DirectoryReconciler.java deleted file mode 100644 index 5d645e9d5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DirectoryReconciler.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import io.micrometer.core.instrument.Metrics; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Function; -import javax.ws.rs.ProcessingException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse.Status; - -public class DirectoryReconciler extends AccountDatabaseCrawlerListener { - - private static final Logger logger = LoggerFactory.getLogger(DirectoryReconciler.class); - private static final String SEND_TIMER_NAME = name(DirectoryReconciler.class, "sendRequest"); - - private final String replicationName; - private final DirectoryReconciliationClient reconciliationClient; - private final DynamicConfigurationManager dynamicConfigurationManager; - - public DirectoryReconciler(String replicationName, DirectoryReconciliationClient reconciliationClient, - DynamicConfigurationManager dynamicConfigurationManager) { - this.reconciliationClient = reconciliationClient; - this.replicationName = replicationName; - this.dynamicConfigurationManager = dynamicConfigurationManager; - } - - @Override - public void onCrawlStart() { - } - - @Override - public void onCrawlEnd(Optional fromUuid) { - if (!dynamicConfigurationManager.getConfiguration().getDirectoryReconcilerConfiguration().isEnabled()) { - return; - } - - reconciliationClient.complete(); - } - - @Override - protected void onCrawlChunk(final Optional fromUuid, final List accounts) - throws AccountDatabaseCrawlerRestartException { - - if (!dynamicConfigurationManager.getConfiguration().getDirectoryReconcilerConfiguration().isEnabled()) { - return; - } - - final DirectoryReconciliationRequest addUsersRequest; - final DirectoryReconciliationRequest deleteUsersRequest; - { - final List addedUsers = new ArrayList<>(accounts.size()); - final List deletedUsers = new ArrayList<>(accounts.size()); - - accounts.forEach(account -> { - if (account.shouldBeVisibleInDirectory()) { - addedUsers.add(new DirectoryReconciliationRequest.User(account.getUuid(), account.getNumber())); - } else { - deletedUsers.add(new DirectoryReconciliationRequest.User(account.getUuid(), account.getNumber())); - } - }); - - addUsersRequest = new DirectoryReconciliationRequest(addedUsers); - deleteUsersRequest = new DirectoryReconciliationRequest(deletedUsers); - } - - final DirectoryReconciliationResponse addUsersResponse = sendAdditions(addUsersRequest); - final DirectoryReconciliationResponse deleteUsersResponse = sendDeletes(deleteUsersRequest); - - if (addUsersResponse.getStatus() == DirectoryReconciliationResponse.Status.MISSING - || deleteUsersResponse.getStatus() == Status.MISSING) { - - throw new AccountDatabaseCrawlerRestartException("directory reconciler missing"); - } - } - - private DirectoryReconciliationResponse sendDeletes(final DirectoryReconciliationRequest request) { - return sendRequest(request, reconciliationClient::delete, "delete"); - - } - - private DirectoryReconciliationResponse sendAdditions(final DirectoryReconciliationRequest request) { - return sendRequest(request, reconciliationClient::add, "add"); - } - - private DirectoryReconciliationResponse sendRequest(final DirectoryReconciliationRequest request, - final Function requestHandler, - final String context) { - - return Metrics.timer(SEND_TIMER_NAME, "context", context, "replication", replicationName) - .record(() -> { - try { - final DirectoryReconciliationResponse response = requestHandler.apply(request); - - if (response.getStatus() != DirectoryReconciliationResponse.Status.OK) { - logger.warn("reconciliation error: {} ({})", response.getStatus(), context); - } - return response; - } catch (ProcessingException ex) { - logger.warn("request error: ", ex); - throw new ProcessingException(ex); - } - }); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DirectoryReconciliationClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DirectoryReconciliationClient.java deleted file mode 100644 index ce0fc4318..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DirectoryReconciliationClient.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import java.security.KeyStore; -import java.security.cert.CertificateException; -import javax.net.ssl.SSLContext; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.ClientBuilder; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import org.glassfish.jersey.SslConfigurator; -import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature; -import org.whispersystems.textsecuregcm.configuration.DirectoryServerConfiguration; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse; -import org.whispersystems.textsecuregcm.util.CertificateUtil; - -public class DirectoryReconciliationClient { - - private final String replicationUrl; - private final Client client; - - public DirectoryReconciliationClient(DirectoryServerConfiguration directoryServerConfiguration) - throws CertificateException - { - this.replicationUrl = directoryServerConfiguration.getReplicationUrl(); - this.client = initializeClient(directoryServerConfiguration); - } - - public DirectoryReconciliationResponse add(DirectoryReconciliationRequest request) { - return client.target(replicationUrl) - .path("/v3/directory/exists") - .request(MediaType.APPLICATION_JSON_TYPE) - .put(Entity.json(request), DirectoryReconciliationResponse.class); - } - - public DirectoryReconciliationResponse delete(DirectoryReconciliationRequest request) { - return client.target(replicationUrl) - .path("/v3/directory/deletes") - .request(MediaType.APPLICATION_JSON_TYPE) - .put(Entity.json(request), DirectoryReconciliationResponse.class); - } - - public DirectoryReconciliationResponse complete() { - return client.target(replicationUrl) - .path("/v3/directory/complete") - .request(MediaType.APPLICATION_JSON_TYPE) - .post(null, DirectoryReconciliationResponse.class); - } - - private static Client initializeClient(DirectoryServerConfiguration directoryServerConfiguration) - throws CertificateException { - KeyStore trustStore = CertificateUtil.buildKeyStoreForPem( - directoryServerConfiguration.getReplicationCaCertificates().toArray(new String[0])); - SSLContext sslContext = SslConfigurator.newInstance() - .securityProtocol("TLSv1.2") - .trustStore(trustStore) - .createSSLContext(); - - return ClientBuilder.newBuilder() - .register( - HttpAuthenticationFeature.basic("signal", directoryServerConfiguration.getReplicationPassword().getBytes())) - .sslContext(sslContext) - .build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java deleted file mode 100644 index b70190600..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java +++ /dev/null @@ -1,180 +0,0 @@ -package org.whispersystems.textsecuregcm.storage; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import java.time.Duration; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; -import javax.validation.ConstraintViolation; -import javax.validation.Validation; -import javax.validation.Validator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Util; -import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; -import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; -import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest; -import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse; -import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest; -import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionResponse; - -public class DynamicConfigurationManager { - - private final String application; - private final String environment; - private final String configurationName; - private final AppConfigDataClient appConfigClient; - private final Class configurationClass; - - // Set on initial config fetch - private final AtomicReference configuration = new AtomicReference<>(); - private String configurationToken = null; - private boolean initialized = false; - - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(new YAMLFactory()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule(new JavaTimeModule()); - - private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator(); - - private static final String ERROR_COUNTER_NAME = name(DynamicConfigurationManager.class, "error"); - private static final String ERROR_TYPE_TAG_NAME = "type"; - private static final String CONFIG_CLASS_TAG_NAME = "configClass"; - - private static final Logger logger = LoggerFactory.getLogger(DynamicConfigurationManager.class); - - public DynamicConfigurationManager(String application, String environment, String configurationName, - Class configurationClass) { - this(AppConfigDataClient - .builder() - .overrideConfiguration(ClientOverrideConfiguration.builder() - .apiCallTimeout(Duration.ofSeconds(10)) - .apiCallAttemptTimeout(Duration.ofSeconds(10)).build()) - .build(), - application, environment, configurationName, configurationClass); - } - - @VisibleForTesting - DynamicConfigurationManager(AppConfigDataClient appConfigClient, String application, String environment, - String configurationName, Class configurationClass) { - this.appConfigClient = appConfigClient; - this.application = application; - this.environment = environment; - this.configurationName = configurationName; - this.configurationClass = configurationClass; - } - - public T getConfiguration() { - synchronized (this) { - while (!initialized) { - Util.wait(this); - } - } - return configuration.get(); - } - - public void start() { - configuration.set(retrieveInitialDynamicConfiguration()); - synchronized (this) { - this.initialized = true; - this.notifyAll(); - } - - final Thread workerThread = new Thread(() -> { - while (true) { - try { - retrieveDynamicConfiguration().ifPresent(configuration::set); - } catch (Exception e) { - logger.warn("Error retrieving dynamic configuration", e); - } - - Util.sleep(5000); - } - }, "DynamicConfigurationManagerWorker"); - - workerThread.setDaemon(true); - workerThread.start(); - } - - private Optional retrieveDynamicConfiguration() throws JsonProcessingException { - if (configurationToken == null) { - logger.error("Invalid configuration token, will not be able to fetch configuration updates"); - } - GetLatestConfigurationResponse latestConfiguration; - try { - latestConfiguration = appConfigClient.getLatestConfiguration(GetLatestConfigurationRequest.builder() - .configurationToken(configurationToken) - .build()); - // token to use in the next fetch - configurationToken = latestConfiguration.nextPollConfigurationToken(); - logger.debug("next token: {}", configurationToken); - } catch (final RuntimeException e) { - Metrics.counter(ERROR_COUNTER_NAME, ERROR_TYPE_TAG_NAME, "fetch").increment(); - throw e; - } - - if (!latestConfiguration.configuration().asByteBuffer().hasRemaining()) { - // empty configuration means nothing has changed - return Optional.empty(); - } - logger.info("Received new config of length {}, next configuration token: {}", - latestConfiguration.configuration().asByteBuffer().remaining(), - configurationToken); - - try { - return parseConfiguration(latestConfiguration.configuration().asUtf8String(), configurationClass); - } catch (final JsonProcessingException e) { - Metrics.counter(ERROR_COUNTER_NAME, - ERROR_TYPE_TAG_NAME, "parse", - CONFIG_CLASS_TAG_NAME, configurationClass.getName()).increment(); - throw e; - } - } - - @VisibleForTesting - public static Optional parseConfiguration(final String configurationYaml, final Class configurationClass) - throws JsonProcessingException { - final T configuration = OBJECT_MAPPER.readValue(configurationYaml, configurationClass); - final Set> violations = VALIDATOR.validate(configuration); - - final Optional maybeDynamicConfiguration; - - if (violations.isEmpty()) { - maybeDynamicConfiguration = Optional.of(configuration); - } else { - logger.warn("Failed to validate configuration: {}", violations); - maybeDynamicConfiguration = Optional.empty(); - } - - return maybeDynamicConfiguration; - } - - private T retrieveInitialDynamicConfiguration() { - for (;;) { - try { - if (configurationToken == null) { - // first time around, start the configuration session - final StartConfigurationSessionResponse startResponse = appConfigClient - .startConfigurationSession(StartConfigurationSessionRequest.builder() - .applicationIdentifier(application) - .environmentIdentifier(environment) - .configurationProfileIdentifier(configurationName).build()); - configurationToken = startResponse.initialConfigurationToken(); - } - return retrieveDynamicConfiguration().orElseThrow(() -> new IllegalStateException("No initial configuration available")); - } catch (Exception e) { - logger.warn("Error retrieving initial dynamic configuration", e); - Util.sleep(1000); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java deleted file mode 100644 index 483b4bfd2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.whispersystems.textsecuregcm.util.AttributeValues.b; -import static org.whispersystems.textsecuregcm.util.AttributeValues.n; -import static org.whispersystems.textsecuregcm.util.AttributeValues.s; - -import com.google.common.base.Throwables; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.time.Instant; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.function.Consumer; -import javax.annotation.Nonnull; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import javax.ws.rs.ClientErrorException; -import javax.ws.rs.core.Response.Status; -import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; -import software.amazon.awssdk.services.dynamodb.model.ReturnValue; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -public class IssuedReceiptsManager { - - public static final String KEY_PROCESSOR_ITEM_ID = "A"; // S (HashKey) - public static final String KEY_ISSUED_RECEIPT_TAG = "B"; // B - public static final String KEY_EXPIRATION = "E"; // N - - private final String table; - private final Duration expiration; - private final DynamoDbAsyncClient dynamoDbAsyncClient; - private final byte[] receiptTagGenerator; - - public IssuedReceiptsManager( - @Nonnull String table, - @Nonnull Duration expiration, - @Nonnull DynamoDbAsyncClient dynamoDbAsyncClient, - @Nonnull byte[] receiptTagGenerator) { - this.table = Objects.requireNonNull(table); - this.expiration = Objects.requireNonNull(expiration); - this.dynamoDbAsyncClient = Objects.requireNonNull(dynamoDbAsyncClient); - this.receiptTagGenerator = Objects.requireNonNull(receiptTagGenerator); - } - - /** - * Returns a future that completes normally if either this processor item was never issued a receipt credential - * previously OR if it was issued a receipt credential previously for the exact same receipt credential request - * enabling clients to retry in case they missed the original response. - *

- * If this item has already been used to issue another receipt, throws a 409 conflict web application exception. - *

- * For {@link SubscriptionProcessor#STRIPE}, item is expected to refer to an invoice line item (subscriptions) or a - * payment intent (one-time). - */ - public CompletableFuture recordIssuance( - String processorItemId, - SubscriptionProcessor processor, - ReceiptCredentialRequest request, - Instant now) { - - final AttributeValue key; - if (processor == SubscriptionProcessor.STRIPE) { - // As the first processor, Stripe’s IDs were not prefixed. Its item IDs have documented prefixes (`il_`, `pi_`) - // that will not collide with `SubscriptionProcessor` names - key = s(processorItemId); - } else { - key = s(processor.name() + "_" + processorItemId); - } - UpdateItemRequest updateItemRequest = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_PROCESSOR_ITEM_ID, key)) - .conditionExpression("attribute_not_exists(#key) OR #tag = :tag") - .returnValues(ReturnValue.NONE) - .updateExpression("SET " - + "#tag = if_not_exists(#tag, :tag), " - + "#exp = if_not_exists(#exp, :exp)") - .expressionAttributeNames(Map.of( - "#key", KEY_PROCESSOR_ITEM_ID, - "#tag", KEY_ISSUED_RECEIPT_TAG, - "#exp", KEY_EXPIRATION)) - .expressionAttributeValues(Map.of( - ":tag", b(generateIssuedReceiptTag(request)), - ":exp", n(now.plus(expiration).getEpochSecond()))) - .build(); - return dynamoDbAsyncClient.updateItem(updateItemRequest).handle((updateItemResponse, throwable) -> { - if (throwable != null) { - Throwable rootCause = Throwables.getRootCause(throwable); - if (rootCause instanceof ConditionalCheckFailedException) { - throw new ClientErrorException(Status.CONFLICT, rootCause); - } - Throwables.throwIfUnchecked(throwable); - throw new CompletionException(throwable); - } - return null; - }); - } - - private byte[] generateIssuedReceiptTag(ReceiptCredentialRequest request) { - return generateHmac("issuedReceiptTag", mac -> mac.update(request.serialize())); - } - - private byte[] generateHmac(String type, Consumer byteConsumer) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(receiptTagGenerator, "HmacSHA256")); - mac.update(type.getBytes(StandardCharsets.UTF_8)); - byteConsumer.accept(mac); - return mac.doFinal(); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new AssertionError(e); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Keys.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Keys.java deleted file mode 100644 index 9f1a99276..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Keys.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.DistributionSummary; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import org.whispersystems.textsecuregcm.entities.PreKey; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse; -import software.amazon.awssdk.services.dynamodb.model.DeleteRequest; -import software.amazon.awssdk.services.dynamodb.model.PutRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryResponse; -import software.amazon.awssdk.services.dynamodb.model.ReturnValue; -import software.amazon.awssdk.services.dynamodb.model.Select; -import software.amazon.awssdk.services.dynamodb.model.WriteRequest; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -public class Keys extends AbstractDynamoDbStore { - - private final String tableName; - - static final String KEY_ACCOUNT_UUID = "U"; - static final String KEY_DEVICE_ID_KEY_ID = "DK"; - static final String KEY_PUBLIC_KEY = "P"; - - private static final Timer STORE_KEYS_TIMER = Metrics.timer(name(Keys.class, "storeKeys")); - private static final Timer TAKE_KEY_FOR_DEVICE_TIMER = Metrics.timer(name(Keys.class, "takeKeyForDevice")); - private static final Timer GET_KEY_COUNT_TIMER = Metrics.timer(name(Keys.class, "getKeyCount")); - private static final Timer DELETE_KEYS_FOR_DEVICE_TIMER = Metrics.timer(name(Keys.class, "deleteKeysForDevice")); - private static final Timer DELETE_KEYS_FOR_ACCOUNT_TIMER = Metrics.timer(name(Keys.class, "deleteKeysForAccount")); - private static final DistributionSummary CONTESTED_KEY_DISTRIBUTION = Metrics.summary(name(Keys.class, "contestedKeys")); - private static final DistributionSummary KEY_COUNT_DISTRIBUTION = Metrics.summary(name(Keys.class, "keyCount")); - - public Keys(final DynamoDbClient dynamoDB, final String tableName) { - super(dynamoDB); - this.tableName = tableName; - } - - public void store(final UUID identifier, final long deviceId, final List keys) { - STORE_KEYS_TIMER.record(() -> { - delete(identifier, deviceId); - - writeInBatches(keys, batch -> { - List items = new ArrayList<>(); - for (final PreKey preKey : batch) { - items.add(WriteRequest.builder() - .putRequest(PutRequest.builder() - .item(getItemFromPreKey(identifier, deviceId, preKey)) - .build()) - .build()); - } - executeTableWriteItemsUntilComplete(Map.of(tableName, items)); - }); - }); - } - - public Optional take(final UUID identifier, final long deviceId) { - return TAKE_KEY_FOR_DEVICE_TIMER.record(() -> { - final AttributeValue partitionKey = getPartitionKey(identifier); - QueryRequest queryRequest = QueryRequest.builder() - .tableName(tableName) - .keyConditionExpression("#uuid = :uuid AND begins_with (#sort, :sortprefix)") - .expressionAttributeNames(Map.of("#uuid", KEY_ACCOUNT_UUID, "#sort", KEY_DEVICE_ID_KEY_ID)) - .expressionAttributeValues(Map.of( - ":uuid", partitionKey, - ":sortprefix", getSortKeyPrefix(deviceId))) - .projectionExpression(KEY_DEVICE_ID_KEY_ID) - .consistentRead(false) - .build(); - - int contestedKeys = 0; - - try { - QueryResponse response = db().query(queryRequest); - for (Map candidate : response.items()) { - DeleteItemRequest deleteItemRequest = DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of( - KEY_ACCOUNT_UUID, partitionKey, - KEY_DEVICE_ID_KEY_ID, candidate.get(KEY_DEVICE_ID_KEY_ID))) - .returnValues(ReturnValue.ALL_OLD) - .build(); - DeleteItemResponse deleteItemResponse = db().deleteItem(deleteItemRequest); - if (deleteItemResponse.hasAttributes()) { - return Optional.of(getPreKeyFromItem(deleteItemResponse.attributes())); - } - - contestedKeys++; - } - - return Optional.empty(); - } finally { - CONTESTED_KEY_DISTRIBUTION.record(contestedKeys); - } - }); - } - - public int getCount(final UUID identifier, final long deviceId) { - return GET_KEY_COUNT_TIMER.record(() -> { - QueryRequest queryRequest = QueryRequest.builder() - .tableName(tableName) - .keyConditionExpression("#uuid = :uuid AND begins_with (#sort, :sortprefix)") - .expressionAttributeNames(Map.of("#uuid", KEY_ACCOUNT_UUID, "#sort", KEY_DEVICE_ID_KEY_ID)) - .expressionAttributeValues(Map.of( - ":uuid", getPartitionKey(identifier), - ":sortprefix", getSortKeyPrefix(deviceId))) - .select(Select.COUNT) - .consistentRead(false) - .build(); - - int keyCount = 0; - // This is very confusing, but does appear to be the intended behavior. See: - // - // - https://github.com/aws/aws-sdk-java/issues/693 - // - https://github.com/aws/aws-sdk-java/issues/915 - // - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html#Query.Count - for (final QueryResponse page : db().queryPaginator(queryRequest)) { - keyCount += page.count(); - } - KEY_COUNT_DISTRIBUTION.record(keyCount); - return keyCount; - }); - } - - public void delete(final UUID accountUuid) { - DELETE_KEYS_FOR_ACCOUNT_TIMER.record(() -> { - final QueryRequest queryRequest = QueryRequest.builder() - .tableName(tableName) - .keyConditionExpression("#uuid = :uuid") - .expressionAttributeNames(Map.of("#uuid", KEY_ACCOUNT_UUID)) - .expressionAttributeValues(Map.of( - ":uuid", getPartitionKey(accountUuid))) - .projectionExpression(KEY_DEVICE_ID_KEY_ID) - .consistentRead(true) - .build(); - - deleteItemsForAccountMatchingQuery(accountUuid, queryRequest); - }); - } - - public void delete(final UUID accountUuid, final long deviceId) { - DELETE_KEYS_FOR_DEVICE_TIMER.record(() -> { - final QueryRequest queryRequest = QueryRequest.builder() - .tableName(tableName) - .keyConditionExpression("#uuid = :uuid AND begins_with (#sort, :sortprefix)") - .expressionAttributeNames(Map.of("#uuid", KEY_ACCOUNT_UUID, "#sort", KEY_DEVICE_ID_KEY_ID)) - .expressionAttributeValues(Map.of( - ":uuid", getPartitionKey(accountUuid), - ":sortprefix", getSortKeyPrefix(deviceId))) - .projectionExpression(KEY_DEVICE_ID_KEY_ID) - .consistentRead(true) - .build(); - - deleteItemsForAccountMatchingQuery(accountUuid, queryRequest); - }); - } - - private void deleteItemsForAccountMatchingQuery(final UUID accountUuid, final QueryRequest querySpec) { - final AttributeValue partitionKey = getPartitionKey(accountUuid); - - writeInBatches(db().query(querySpec).items(), batch -> { - List deletes = new ArrayList<>(); - for (final Map item : batch) { - deletes.add(WriteRequest.builder() - .deleteRequest(DeleteRequest.builder() - .key(Map.of( - KEY_ACCOUNT_UUID, partitionKey, - KEY_DEVICE_ID_KEY_ID, item.get(KEY_DEVICE_ID_KEY_ID))) - .build()) - .build()); - } - executeTableWriteItemsUntilComplete(Map.of(tableName, deletes)); - }); - } - - private static AttributeValue getPartitionKey(final UUID accountUuid) { - return AttributeValues.fromUUID(accountUuid); - } - - private static AttributeValue getSortKey(final long deviceId, final long keyId) { - final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]); - byteBuffer.putLong(deviceId); - byteBuffer.putLong(keyId); - return AttributeValues.fromByteBuffer(byteBuffer.flip()); - } - - @VisibleForTesting - static AttributeValue getSortKeyPrefix(final long deviceId) { - final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[8]); - byteBuffer.putLong(deviceId); - return AttributeValues.fromByteBuffer(byteBuffer.flip()); - } - - private Map getItemFromPreKey(final UUID accountUuid, final long deviceId, final PreKey preKey) { - return Map.of( - KEY_ACCOUNT_UUID, getPartitionKey(accountUuid), - KEY_DEVICE_ID_KEY_ID, getSortKey(deviceId, preKey.getKeyId()), - KEY_PUBLIC_KEY, AttributeValues.fromString(preKey.getPublicKey())); - } - - private PreKey getPreKeyFromItem(Map item) { - final long keyId = item.get(KEY_DEVICE_ID_KEY_ID).b().asByteBuffer().getLong(8); - return new PreKey(keyId, item.get(KEY_PUBLIC_KEY).s()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWork.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWork.java deleted file mode 100644 index 9f82db964..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWork.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import io.dropwizard.lifecycle.Managed; -import io.micrometer.core.instrument.Metrics; -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.Util; - -public abstract class ManagedPeriodicWork implements Managed { - - private final Logger logger = LoggerFactory.getLogger(getClass()); - - private static final String FUTURE_DONE_GAUGE_NAME = "futureDone"; - - private final ManagedPeriodicWorkLock lock; - private final Duration workerTtl; - private final Duration runInterval; - private final String workerId; - private final ScheduledExecutorService executorService; - - private Duration sleepDurationAfterUnexpectedException = Duration.ofSeconds(10); - - - @Nullable - private ScheduledFuture scheduledFuture; - private AtomicReference> activeExecutionFuture = new AtomicReference<>(CompletableFuture.completedFuture(null)); - - public ManagedPeriodicWork(final ManagedPeriodicWorkLock lock, final Duration workerTtl, final Duration runInterval, final ScheduledExecutorService scheduledExecutorService) { - this.lock = lock; - this.workerTtl = workerTtl; - this.runInterval = runInterval; - this.workerId = UUID.randomUUID().toString(); - this.executorService = scheduledExecutorService; - } - - abstract protected void doPeriodicWork() throws Exception; - - @Override - public synchronized void start() throws Exception { - - if (scheduledFuture != null) { - return; - } - - scheduledFuture = executorService.scheduleAtFixedRate(() -> { - try { - execute(); - } catch (final Exception e) { - logger.warn("Error in execution", e); - - // wait a bit, in case the error is caused by external instability - Util.sleep(sleepDurationAfterUnexpectedException.toMillis()); - } - }, 0, runInterval.getSeconds(), TimeUnit.SECONDS); - - Metrics.gauge(name(getClass(), FUTURE_DONE_GAUGE_NAME), scheduledFuture, future -> future.isDone() ? 1 : 0); - } - - @Override - public synchronized void stop() throws Exception { - - if (scheduledFuture != null) { - - scheduledFuture.cancel(false); - - try { - activeExecutionFuture.get().join(); - } catch (final Exception e) { - logger.warn("error while awaiting final execution", e); - } - } - } - - public void setSleepDurationAfterUnexpectedException(final Duration sleepDurationAfterUnexpectedException) { - this.sleepDurationAfterUnexpectedException = sleepDurationAfterUnexpectedException; - } - - private void execute() { - - if (lock.claimActiveWork(workerId, workerTtl)) { - try { - - activeExecutionFuture.set(new CompletableFuture<>()); - - logger.info("Starting execution"); - doPeriodicWork(); - logger.info("Execution complete"); - - } catch (final Exception e) { - logger.warn("Periodic work failed", e); - - // wait a bit, in case the error is caused by external instability - Util.sleep(sleepDurationAfterUnexpectedException.toMillis()); - - } finally { - try { - lock.releaseActiveWork(workerId); - } finally { - activeExecutionFuture.get().complete(null); - } - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWorkLock.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWorkLock.java deleted file mode 100644 index 5ccbfae10..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWorkLock.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import io.lettuce.core.ScriptOutputType; -import io.lettuce.core.SetArgs; -import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import java.io.IOException; -import java.time.Duration; -import java.util.List; - -public class ManagedPeriodicWorkLock { - - private final String activeWorkerKey; - - private final FaultTolerantRedisCluster cacheCluster; - private final ClusterLuaScript unlockClusterScript; - - public ManagedPeriodicWorkLock(final String activeWorkerKey, final FaultTolerantRedisCluster cacheCluster) throws IOException { - this.activeWorkerKey = activeWorkerKey; - this.cacheCluster = cacheCluster; - this.unlockClusterScript = ClusterLuaScript.fromResource(cacheCluster, "lua/periodic_worker/unlock.lua", ScriptOutputType.INTEGER); - } - - public boolean claimActiveWork(String workerId, Duration ttl) { - return "OK".equals(cacheCluster.withCluster(connection -> connection.sync().set(activeWorkerKey, workerId, SetArgs.Builder.nx().px(ttl.toMillis())))); - } - - public void releaseActiveWork(String workerId) { - unlockClusterScript.execute(List.of(activeWorkerKey), List.of(workerId)); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessageAvailabilityListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessageAvailabilityListener.java deleted file mode 100644 index e7fed470a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessageAvailabilityListener.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -/** - * A message availability listener is notified when new messages are available for a specific device for a specific - * account. Availability listeners are also notified when messages are moved from the message cache to long-term storage - * as an optimization hint to implementing classes. - */ -public interface MessageAvailabilityListener { - - /** - * @return whether the listener is still active. {@code false} indicates the listener can no longer handle messages - * and may be discarded - */ - boolean handleNewMessagesAvailable(); - - /** - * @return whether the listener is still active. {@code false} indicates the listener can no longer handle messages - * and may be discarded - */ - boolean handleMessagesPersisted(); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersistenceException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersistenceException.java deleted file mode 100644 index 3e96a8fc4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersistenceException.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -public class MessagePersistenceException extends Exception { - - public MessagePersistenceException(String message) { - super(message); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersister.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersister.java deleted file mode 100644 index baa532f30..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersister.java +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Histogram; -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.lifecycle.Managed; -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.Util; - -public class MessagePersister implements Managed { - - private final MessagesCache messagesCache; - private final MessagesManager messagesManager; - private final AccountsManager accountsManager; - - private final Duration persistDelay; - - private final Thread[] workerThreads = new Thread[WORKER_THREAD_COUNT]; - private volatile boolean running; - - private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private final Timer getQueuesTimer = metricRegistry.timer(name(MessagePersister.class, "getQueues")); - private final Timer persistQueueTimer = metricRegistry.timer(name(MessagePersister.class, "persistQueue")); - private final Meter persistQueueExceptionMeter = metricRegistry.meter( - name(MessagePersister.class, "persistQueueException")); - private final Histogram queueCountHistogram = metricRegistry.histogram(name(MessagePersister.class, "queueCount")); - private final Histogram queueSizeHistogram = metricRegistry.histogram(name(MessagePersister.class, "queueSize")); - - static final int QUEUE_BATCH_LIMIT = 100; - static final int MESSAGE_BATCH_LIMIT = 100; - - private static final long EXCEPTION_PAUSE_MILLIS = Duration.ofSeconds(3).toMillis(); - - private static final int WORKER_THREAD_COUNT = 4; - - private static final int CONSECUTIVE_EMPTY_CACHE_REMOVAL_LIMIT = 3; - - private static final Logger logger = LoggerFactory.getLogger(MessagePersister.class); - - public MessagePersister(final MessagesCache messagesCache, final MessagesManager messagesManager, - final AccountsManager accountsManager, - final DynamicConfigurationManager dynamicConfigurationManager, - final Duration persistDelay) { - this.messagesCache = messagesCache; - this.messagesManager = messagesManager; - this.accountsManager = accountsManager; - this.persistDelay = persistDelay; - - for (int i = 0; i < workerThreads.length; i++) { - workerThreads[i] = new Thread(() -> { - while (running) { - if (dynamicConfigurationManager.getConfiguration().getMessagePersisterConfiguration() - .isPersistenceEnabled()) { - try { - final int queuesPersisted = persistNextQueues(Instant.now()); - queueCountHistogram.update(queuesPersisted); - - if (queuesPersisted == 0) { - Util.sleep(100); - } - } catch (final Throwable t) { - logger.warn("Failed to persist queues", t); - Util.sleep(EXCEPTION_PAUSE_MILLIS); - } - } else { - Util.sleep(1000); - } - } - }, "MessagePersisterWorker-" + i); - } - } - - @VisibleForTesting - Duration getPersistDelay() { - return persistDelay; - } - - @Override - public void start() { - running = true; - - for (final Thread workerThread : workerThreads) { - workerThread.start(); - } - } - - @Override - public void stop() { - running = false; - - for (final Thread workerThread : workerThreads) { - try { - workerThread.join(); - } catch (final InterruptedException e) { - logger.warn("Interrupted while waiting for worker thread to complete current operation"); - } - } - } - - @VisibleForTesting - int persistNextQueues(final Instant currentTime) { - final int slot = messagesCache.getNextSlotToPersist(); - - List queuesToPersist; - int queuesPersisted = 0; - - do { - try (final Timer.Context ignored = getQueuesTimer.time()) { - queuesToPersist = messagesCache.getQueuesToPersist(slot, currentTime.minus(persistDelay), QUEUE_BATCH_LIMIT); - } - - for (final String queue : queuesToPersist) { - final UUID accountUuid = MessagesCache.getAccountUuidFromQueueName(queue); - final long deviceId = MessagesCache.getDeviceIdFromQueueName(queue); - - try { - persistQueue(accountUuid, deviceId); - } catch (final Exception e) { - persistQueueExceptionMeter.mark(); - logger.warn("Failed to persist queue {}::{}; will schedule for retry", accountUuid, deviceId, e); - - messagesCache.addQueueToPersist(accountUuid, deviceId); - - Util.sleep(EXCEPTION_PAUSE_MILLIS); - } - } - - queuesPersisted += queuesToPersist.size(); - } while (queuesToPersist.size() >= QUEUE_BATCH_LIMIT); - - return queuesPersisted; - } - - @VisibleForTesting - void persistQueue(final UUID accountUuid, final long deviceId) throws MessagePersistenceException { - final Optional maybeAccount = accountsManager.getByAccountIdentifier(accountUuid); - - if (maybeAccount.isEmpty()) { - logger.error("No account record found for account {}", accountUuid); - return; - } - - try (final Timer.Context ignored = persistQueueTimer.time()) { - messagesCache.lockQueueForPersistence(accountUuid, deviceId); - - try { - int messageCount = 0; - List messages; - - int consecutiveEmptyCacheRemovals = 0; - - do { - messages = messagesCache.getMessagesToPersist(accountUuid, deviceId, MESSAGE_BATCH_LIMIT); - - int messagesRemovedFromCache = messagesManager.persistMessages(accountUuid, deviceId, messages); - messageCount += messages.size(); - - if (messagesRemovedFromCache == 0) { - consecutiveEmptyCacheRemovals += 1; - } else { - consecutiveEmptyCacheRemovals = 0; - } - - if (consecutiveEmptyCacheRemovals > CONSECUTIVE_EMPTY_CACHE_REMOVAL_LIMIT) { - throw new MessagePersistenceException("persistence failure loop detected"); - } - - } while (!messages.isEmpty()); - - queueSizeHistogram.update(messageCount); - } finally { - messagesCache.unlockQueueForPersistence(accountUuid, deviceId); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCache.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCache.java deleted file mode 100644 index 986aa3b4c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCache.java +++ /dev/null @@ -1,515 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.google.common.annotations.VisibleForTesting; -import com.google.protobuf.InvalidProtocolBufferException; -import io.dropwizard.lifecycle.Managed; -import io.lettuce.core.ScoredValue; -import io.lettuce.core.ScriptOutputType; -import io.lettuce.core.ZAddArgs; -import io.lettuce.core.cluster.SlotHash; -import io.lettuce.core.cluster.event.ClusterTopologyChangedEvent; -import io.lettuce.core.cluster.models.partitions.RedisClusterNode; -import io.lettuce.core.cluster.pubsub.RedisClusterPubSubAdapter; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import javax.annotation.Nullable; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; -import org.whispersystems.textsecuregcm.redis.ClusterLuaScript; -import org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.RedisClusterUtil; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; - -public class MessagesCache extends RedisClusterPubSubAdapter implements Managed { - - private final FaultTolerantRedisCluster readDeleteCluster; - private final FaultTolerantPubSubConnection pubSubConnection; - private final Clock clock; - - private final ExecutorService notificationExecutorService; - private final ExecutorService messageDeletionExecutorService; - // messageDeletionExecutorService wrapped into a reactor Scheduler - private final Scheduler messageDeletionScheduler; - - private final ClusterLuaScript insertScript; - private final ClusterLuaScript removeByGuidScript; - private final ClusterLuaScript getItemsScript; - private final ClusterLuaScript removeQueueScript; - private final ClusterLuaScript getQueuesToPersistScript; - - private final Map messageListenersByQueueName = new HashMap<>(); - private final Map queueNamesByMessageListener = new IdentityHashMap<>(); - - private final Timer insertTimer = Metrics.timer(name(MessagesCache.class, "insert")); - private final Timer getMessagesTimer = Metrics.timer(name(MessagesCache.class, "get")); - private final Timer getQueuesToPersistTimer = Metrics.timer(name(MessagesCache.class, "getQueuesToPersist")); - private final Timer clearQueueTimer = Metrics.timer(name(MessagesCache.class, "clear")); - private final Counter pubSubMessageCounter = Metrics.counter(name(MessagesCache.class, "pubSubMessage")); - private final Counter newMessageNotificationCounter = Metrics.counter( - name(MessagesCache.class, "newMessageNotification")); - private final Counter queuePersistedNotificationCounter = Metrics.counter( - name(MessagesCache.class, "queuePersisted")); - private final Counter staleEphemeralMessagesCounter = Metrics.counter( - name(MessagesCache.class, "staleEphemeralMessages")); - - static final String NEXT_SLOT_TO_PERSIST_KEY = "user_queue_persist_slot"; - private static final byte[] LOCK_VALUE = "1".getBytes(StandardCharsets.UTF_8); - - private static final String QUEUE_KEYSPACE_PREFIX = "__keyspace@0__:user_queue::"; - private static final String PERSISTING_KEYSPACE_PREFIX = "__keyspace@0__:user_queue_persisting::"; - - @VisibleForTesting - static final Duration MAX_EPHEMERAL_MESSAGE_DELAY = Duration.ofSeconds(10); - - private static final String GET_FLUX_NAME = MetricsUtil.name(MessagesCache.class, "get"); - private static final int PAGE_SIZE = 100; - - private static final Logger logger = LoggerFactory.getLogger(MessagesCache.class); - - public MessagesCache(final FaultTolerantRedisCluster insertCluster, final FaultTolerantRedisCluster readDeleteCluster, - final Clock clock, final ExecutorService notificationExecutorService, - final ExecutorService messageDeletionExecutorService) throws IOException { - - this.readDeleteCluster = readDeleteCluster; - this.pubSubConnection = readDeleteCluster.createPubSubConnection(); - this.clock = clock; - - this.notificationExecutorService = notificationExecutorService; - this.messageDeletionExecutorService = messageDeletionExecutorService; - this.messageDeletionScheduler = Schedulers.fromExecutorService(messageDeletionExecutorService, "messageDeletion"); - - this.insertScript = ClusterLuaScript.fromResource(insertCluster, "lua/insert_item.lua", ScriptOutputType.INTEGER); - this.removeByGuidScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/remove_item_by_guid.lua", - ScriptOutputType.MULTI); - this.getItemsScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/get_items.lua", ScriptOutputType.MULTI); - this.removeQueueScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/remove_queue.lua", - ScriptOutputType.STATUS); - this.getQueuesToPersistScript = ClusterLuaScript.fromResource(readDeleteCluster, "lua/get_queues_to_persist.lua", - ScriptOutputType.MULTI); - } - - @Override - public void start() { - pubSubConnection.usePubSubConnection(connection -> { - connection.addListener(this); - connection.getResources().eventBus().get() - .filter(event -> event instanceof ClusterTopologyChangedEvent) - .subscribe(event -> resubscribeAll()); - }); - } - - @Override - public void stop() { - pubSubConnection.usePubSubConnection(connection -> connection.sync().upstream().commands().unsubscribe()); - } - - private void resubscribeAll() { - logger.info("Got topology change event, resubscribing all keyspace notifications"); - - final Set queueNames; - - synchronized (messageListenersByQueueName) { - queueNames = new HashSet<>(messageListenersByQueueName.keySet()); - } - - for (final String queueName : queueNames) { - subscribeForKeyspaceNotifications(queueName); - } - } - - public long insert(final UUID guid, final UUID destinationUuid, final long destinationDevice, - final MessageProtos.Envelope message) { - final MessageProtos.Envelope messageWithGuid = message.toBuilder().setServerGuid(guid.toString()).build(); - return (long) insertTimer.record(() -> - insertScript.executeBinary(List.of(getMessageQueueKey(destinationUuid, destinationDevice), - getMessageQueueMetadataKey(destinationUuid, destinationDevice), - getQueueIndexKey(destinationUuid, destinationDevice)), - List.of(messageWithGuid.toByteArray(), - String.valueOf(message.getTimestamp()).getBytes(StandardCharsets.UTF_8), - guid.toString().getBytes(StandardCharsets.UTF_8)))); - } - - public CompletableFuture> remove(final UUID destinationUuid, - final long destinationDevice, - final UUID messageGuid) { - - return remove(destinationUuid, destinationDevice, List.of(messageGuid)) - .thenApply(removed -> removed.isEmpty() ? Optional.empty() : Optional.of(removed.get(0))); - } - - @SuppressWarnings("unchecked") - public CompletableFuture> remove(final UUID destinationUuid, - final long destinationDevice, - final List messageGuids) { - - return removeByGuidScript.executeBinaryAsync(List.of(getMessageQueueKey(destinationUuid, destinationDevice), - getMessageQueueMetadataKey(destinationUuid, destinationDevice), - getQueueIndexKey(destinationUuid, destinationDevice)), - messageGuids.stream().map(guid -> guid.toString().getBytes(StandardCharsets.UTF_8)) - .collect(Collectors.toList())) - .thenApplyAsync(result -> { - List serialized = (List) result; - - final List removedMessages = new ArrayList<>(serialized.size()); - - for (final byte[] bytes : serialized) { - try { - removedMessages.add(MessageProtos.Envelope.parseFrom(bytes)); - } catch (final InvalidProtocolBufferException e) { - logger.warn("Failed to parse envelope", e); - } - } - - return removedMessages; - }, messageDeletionExecutorService); - } - - public boolean hasMessages(final UUID destinationUuid, final long destinationDevice) { - return readDeleteCluster.withBinaryCluster( - connection -> connection.sync().zcard(getMessageQueueKey(destinationUuid, destinationDevice)) > 0); - } - - public Publisher get(final UUID destinationUuid, final long destinationDevice) { - - final long earliestAllowableEphemeralTimestamp = - clock.millis() - MAX_EPHEMERAL_MESSAGE_DELAY.toMillis(); - - final Flux allMessages = getAllMessages(destinationUuid, destinationDevice) - .publish() - // We expect exactly two subscribers to this base flux: - // 1. the websocket that delivers messages to clients - // 2. an internal process to discard stale ephemeral messages - // The discard subscriber will subscribe immediately, but we don’t want to do any work if the - // websocket never subscribes. - .autoConnect(2); - - final Flux messagesToPublish = allMessages - .filter(Predicate.not(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp))); - - final Flux staleEphemeralMessages = allMessages - .filter(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestamp)); - - discardStaleEphemeralMessages(destinationUuid, destinationDevice, staleEphemeralMessages); - - return messagesToPublish.name(GET_FLUX_NAME) - .metrics(); - } - - private static boolean isStaleEphemeralMessage(final MessageProtos.Envelope message, - long earliestAllowableTimestamp) { - return message.hasEphemeral() && message.getEphemeral() && message.getTimestamp() < earliestAllowableTimestamp; - } - - private void discardStaleEphemeralMessages(final UUID destinationUuid, final long destinationDevice, - Flux staleEphemeralMessages) { - staleEphemeralMessages - .map(e -> UUID.fromString(e.getServerGuid())) - .buffer(PAGE_SIZE) - .subscribeOn(messageDeletionScheduler) - .subscribe(staleEphemeralMessageGuids -> - remove(destinationUuid, destinationDevice, staleEphemeralMessageGuids) - .thenAccept(removedMessages -> staleEphemeralMessagesCounter.increment(removedMessages.size())), - e -> logger.warn("Could not remove stale ephemeral messages from cache", e)); - } - - @VisibleForTesting - Flux getAllMessages(final UUID destinationUuid, final long destinationDevice) { - - // fetch messages by page - return getNextMessagePage(destinationUuid, destinationDevice, -1) - .expand(queueItemsAndLastMessageId -> { - // expand() is breadth-first, so each page will be published in order - if (queueItemsAndLastMessageId.first().isEmpty()) { - return Mono.empty(); - } - - return getNextMessagePage(destinationUuid, destinationDevice, queueItemsAndLastMessageId.second()); - }) - .limitRate(1) - // we want to ensure we don’t accidentally block the Lettuce/netty i/o executors - .publishOn(Schedulers.boundedElastic()) - .map(Pair::first) - .flatMapIterable(queueItems -> { - final List envelopes = new ArrayList<>(queueItems.size() / 2); - - for (int i = 0; i < queueItems.size() - 1; i += 2) { - try { - final MessageProtos.Envelope message = MessageProtos.Envelope.parseFrom(queueItems.get(i)); - - envelopes.add(message); - } catch (InvalidProtocolBufferException e) { - logger.warn("Failed to parse envelope", e); - } - } - - return envelopes; - }); - } - - private Flux, Long>> getNextMessagePage(final UUID destinationUuid, final long destinationDevice, - long messageId) { - - return getItemsScript.executeBinaryReactive( - List.of(getMessageQueueKey(destinationUuid, destinationDevice), - getPersistInProgressKey(destinationUuid, destinationDevice)), - List.of(String.valueOf(PAGE_SIZE).getBytes(StandardCharsets.UTF_8), - String.valueOf(messageId).getBytes(StandardCharsets.UTF_8))) - .map(result -> { - logger.trace("Processing page: {}", messageId); - - @SuppressWarnings("unchecked") - List queueItems = (List) result; - - if (queueItems.isEmpty()) { - return new Pair<>(Collections.emptyList(), null); - } - - if (queueItems.size() % 2 != 0) { - logger.error("\"Get messages\" operation returned a list with a non-even number of elements."); - return new Pair<>(Collections.emptyList(), null); - } - - final long lastMessageId = Long.parseLong( - new String(queueItems.get(queueItems.size() - 1), StandardCharsets.UTF_8)); - - return new Pair<>(queueItems, lastMessageId); - }); - } - - @VisibleForTesting - List getMessagesToPersist(final UUID accountUuid, final long destinationDevice, - final int limit) { - return getMessagesTimer.record(() -> { - final List> scoredMessages = readDeleteCluster.withBinaryCluster( - connection -> connection.sync() - .zrangeWithScores(getMessageQueueKey(accountUuid, destinationDevice), 0, limit)); - final List envelopes = new ArrayList<>(scoredMessages.size()); - - for (final ScoredValue scoredMessage : scoredMessages) { - try { - envelopes.add(MessageProtos.Envelope.parseFrom(scoredMessage.getValue())); - } catch (InvalidProtocolBufferException e) { - logger.warn("Failed to parse envelope", e); - } - } - - return envelopes; - }); - } - - public void clear(final UUID destinationUuid) { - // TODO Remove null check in a fully UUID-based world - if (destinationUuid != null) { - for (int i = 1; i < 256; i++) { - clear(destinationUuid, i); - } - } - } - - public void clear(final UUID destinationUuid, final long deviceId) { - clearQueueTimer.record(() -> - removeQueueScript.executeBinary(List.of(getMessageQueueKey(destinationUuid, deviceId), - getMessageQueueMetadataKey(destinationUuid, deviceId), - getQueueIndexKey(destinationUuid, deviceId)), - Collections.emptyList())); - } - - int getNextSlotToPersist() { - return (int) (readDeleteCluster.withCluster(connection -> connection.sync().incr(NEXT_SLOT_TO_PERSIST_KEY)) - % SlotHash.SLOT_COUNT); - } - - List getQueuesToPersist(final int slot, final Instant maxTime, final int limit) { - //noinspection unchecked - return getQueuesToPersistTimer.record(() -> (List) getQueuesToPersistScript.execute( - List.of(new String(getQueueIndexKey(slot), StandardCharsets.UTF_8)), - List.of(String.valueOf(maxTime.toEpochMilli()), - String.valueOf(limit)))); - } - - void addQueueToPersist(final UUID accountUuid, final long deviceId) { - readDeleteCluster.useBinaryCluster(connection -> connection.sync() - .zadd(getQueueIndexKey(accountUuid, deviceId), ZAddArgs.Builder.nx(), System.currentTimeMillis(), - getMessageQueueKey(accountUuid, deviceId))); - } - - void lockQueueForPersistence(final UUID accountUuid, final long deviceId) { - readDeleteCluster.useBinaryCluster( - connection -> connection.sync().setex(getPersistInProgressKey(accountUuid, deviceId), 30, LOCK_VALUE)); - } - - void unlockQueueForPersistence(final UUID accountUuid, final long deviceId) { - readDeleteCluster.useBinaryCluster( - connection -> connection.sync().del(getPersistInProgressKey(accountUuid, deviceId))); - } - - public void addMessageAvailabilityListener(final UUID destinationUuid, final long deviceId, - final MessageAvailabilityListener listener) { - final String queueName = getQueueName(destinationUuid, deviceId); - - synchronized (messageListenersByQueueName) { - messageListenersByQueueName.put(queueName, listener); - queueNamesByMessageListener.put(listener, queueName); - } - - subscribeForKeyspaceNotifications(queueName); - } - - public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) { - @Nullable final String queueName = queueNamesByMessageListener.get(listener); - - if (queueName != null) { - unsubscribeFromKeyspaceNotifications(queueName); - - synchronized (messageListenersByQueueName) { - queueNamesByMessageListener.remove(listener); - messageListenersByQueueName.remove(queueName); - } - } - } - - private void subscribeForKeyspaceNotifications(final String queueName) { - final int slot = SlotHash.getSlot(queueName); - - pubSubConnection.usePubSubConnection( - connection -> connection.sync().nodes(node -> node.is(RedisClusterNode.NodeFlag.UPSTREAM) && node.hasSlot(slot)) - .commands() - .subscribe(getKeyspaceChannels(queueName))); - } - - private void unsubscribeFromKeyspaceNotifications(final String queueName) { - final int slot = SlotHash.getSlot(queueName); - - pubSubConnection.usePubSubConnection( - connection -> connection.sync().nodes(node -> node.is(RedisClusterNode.NodeFlag.UPSTREAM) && node.hasSlot(slot)) - .commands() - .unsubscribe(getKeyspaceChannels(queueName))); - } - - private static String[] getKeyspaceChannels(final String queueName) { - return new String[]{ - QUEUE_KEYSPACE_PREFIX + "{" + queueName + "}", - PERSISTING_KEYSPACE_PREFIX + "{" + queueName + "}" - }; - } - - @Override - public void message(final RedisClusterNode node, final String channel, final String message) { - pubSubMessageCounter.increment(); - - if (channel.startsWith(QUEUE_KEYSPACE_PREFIX) && "zadd".equals(message)) { - newMessageNotificationCounter.increment(); - notificationExecutorService.execute(() -> { - try { - findListener(channel).ifPresent(listener -> { - if (!listener.handleNewMessagesAvailable()) { - removeMessageAvailabilityListener(listener); - } - }); - } catch (final Exception e) { - logger.warn("Unexpected error handling new message", e); - } - }); - } else if (channel.startsWith(PERSISTING_KEYSPACE_PREFIX) && "del".equals(message)) { - queuePersistedNotificationCounter.increment(); - notificationExecutorService.execute(() -> { - try { - findListener(channel).ifPresent(listener -> { - if (!listener.handleMessagesPersisted()) { - removeMessageAvailabilityListener(listener); - } - }); - } catch (final Exception e) { - logger.warn("Unexpected error handling messages persisted", e); - } - }); - } - } - - private Optional findListener(final String keyspaceChannel) { - final String queueName = getQueueNameFromKeyspaceChannel(keyspaceChannel); - - synchronized (messageListenersByQueueName) { - return Optional.ofNullable(messageListenersByQueueName.get(queueName)); - } - } - - @VisibleForTesting - static String getQueueName(final UUID accountUuid, final long deviceId) { - return accountUuid + "::" + deviceId; - } - - @VisibleForTesting - static String getQueueNameFromKeyspaceChannel(final String channel) { - final int startOfHashTag = channel.indexOf('{'); - final int endOfHashTag = channel.lastIndexOf('}'); - - return channel.substring(startOfHashTag + 1, endOfHashTag); - } - - @VisibleForTesting - static byte[] getMessageQueueKey(final UUID accountUuid, final long deviceId) { - return ("user_queue::{" + accountUuid.toString() + "::" + deviceId + "}").getBytes(StandardCharsets.UTF_8); - } - - private static byte[] getMessageQueueMetadataKey(final UUID accountUuid, final long deviceId) { - return ("user_queue_metadata::{" + accountUuid.toString() + "::" + deviceId + "}").getBytes(StandardCharsets.UTF_8); - } - - private static byte[] getQueueIndexKey(final UUID accountUuid, final long deviceId) { - return getQueueIndexKey(SlotHash.getSlot(accountUuid.toString() + "::" + deviceId)); - } - - private static byte[] getQueueIndexKey(final int slot) { - return ("user_queue_index::{" + RedisClusterUtil.getMinimalHashTag(slot) + "}").getBytes(StandardCharsets.UTF_8); - } - - private static byte[] getPersistInProgressKey(final UUID accountUuid, final long deviceId) { - return ("user_queue_persisting::{" + accountUuid + "::" + deviceId + "}").getBytes(StandardCharsets.UTF_8); - } - - static UUID getAccountUuidFromQueueName(final String queueName) { - final int startOfHashTag = queueName.indexOf('{'); - - return UUID.fromString(queueName.substring(startOfHashTag + 1, queueName.indexOf("::", startOfHashTag))); - } - - static long getDeviceIdFromQueueName(final String queueName) { - return Long.parseLong(queueName.substring(queueName.lastIndexOf("::") + 2, queueName.lastIndexOf('}'))); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDb.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDb.java deleted file mode 100644 index ea7740de2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDb.java +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; -import static io.micrometer.core.instrument.Metrics.timer; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableMap; -import com.google.protobuf.InvalidProtocolBufferException; -import io.micrometer.core.instrument.Timer; -import java.nio.ByteBuffer; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.function.Predicate; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.DeleteRequest; -import software.amazon.awssdk.services.dynamodb.model.PutRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.ReturnValue; -import software.amazon.awssdk.services.dynamodb.model.WriteRequest; - -public class MessagesDynamoDb extends AbstractDynamoDbStore { - - private static final String KEY_PARTITION = "H"; - private static final String KEY_SORT = "S"; - - private static final String LOCAL_INDEX_MESSAGE_UUID_NAME = "Message_UUID_Index"; - private static final String LOCAL_INDEX_MESSAGE_UUID_KEY_SORT = "U"; - - private static final String KEY_TTL = "E"; - private static final String KEY_ENVELOPE_BYTES = "EB"; - - private final Timer storeTimer = timer(name(getClass(), "store")); - private final Timer deleteByAccount = timer(name(getClass(), "delete", "account")); - private final Timer deleteByDevice = timer(name(getClass(), "delete", "device")); - - private final DynamoDbAsyncClient dbAsyncClient; - private final String tableName; - private final Duration timeToLive; - private final ExecutorService messageDeletionExecutor; - private final Scheduler messageDeletionScheduler; - - private static final Logger logger = LoggerFactory.getLogger(MessagesDynamoDb.class); - - public MessagesDynamoDb(DynamoDbClient dynamoDb, DynamoDbAsyncClient dynamoDbAsyncClient, String tableName, - Duration timeToLive, ExecutorService messageDeletionExecutor) { - super(dynamoDb); - - this.dbAsyncClient = dynamoDbAsyncClient; - this.tableName = tableName; - this.timeToLive = timeToLive; - - this.messageDeletionExecutor = messageDeletionExecutor; - this.messageDeletionScheduler = Schedulers.fromExecutor(messageDeletionExecutor); - } - - public void store(final List messages, final UUID destinationAccountUuid, final long destinationDeviceId) { - storeTimer.record(() -> writeInBatches(messages, (messageBatch) -> storeBatch(messageBatch, destinationAccountUuid, destinationDeviceId))); - } - - private void storeBatch(final List messages, final UUID destinationAccountUuid, final long destinationDeviceId) { - if (messages.size() > DYNAMO_DB_MAX_BATCH_SIZE) { - throw new IllegalArgumentException("Maximum batch size of " + DYNAMO_DB_MAX_BATCH_SIZE + " exceeded with " + messages.size() + " messages"); - } - - final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid); - List writeItems = new ArrayList<>(); - for (MessageProtos.Envelope message : messages) { - final UUID messageUuid = UUID.fromString(message.getServerGuid()); - - final ImmutableMap.Builder item = ImmutableMap.builder() - .put(KEY_PARTITION, partitionKey) - .put(KEY_SORT, convertSortKey(destinationDeviceId, message.getServerTimestamp(), messageUuid)) - .put(LOCAL_INDEX_MESSAGE_UUID_KEY_SORT, convertLocalIndexMessageUuidSortKey(messageUuid)) - .put(KEY_TTL, AttributeValues.fromLong(getTtlForMessage(message))) - .put(KEY_ENVELOPE_BYTES, AttributeValue.builder().b(SdkBytes.fromByteArray(message.toByteArray())).build()); - - writeItems.add(WriteRequest.builder().putRequest(PutRequest.builder() - .item(item.build()) - .build()).build()); - } - - executeTableWriteItemsUntilComplete(Map.of(tableName, writeItems)); - } - - public Publisher load(final UUID destinationAccountUuid, final long destinationDeviceId, - final Integer limit) { - - final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid); - final QueryRequest.Builder queryRequestBuilder = QueryRequest.builder() - .tableName(tableName) - .consistentRead(true) - .keyConditionExpression("#part = :part AND begins_with ( #sort , :sortprefix )") - .expressionAttributeNames(Map.of( - "#part", KEY_PARTITION, - "#sort", KEY_SORT)) - .expressionAttributeValues(Map.of( - ":part", partitionKey, - ":sortprefix", convertDestinationDeviceIdToSortKeyPrefix(destinationDeviceId))); - - if (limit != null) { - // some callers don’t take advantage of reactive streams, so we want to support limiting the fetch size. Otherwise, - // we could fetch up to 1 MB (likely >1,000 messages) and discard 90% of them - queryRequestBuilder.limit(Math.min(RESULT_SET_CHUNK_SIZE, limit)); - } - - final QueryRequest queryRequest = queryRequestBuilder.build(); - - return dbAsyncClient.queryPaginator(queryRequest).items() - .map(message -> { - try { - return convertItemToEnvelope(message); - } catch (final InvalidProtocolBufferException e) { - logger.error("Failed to parse envelope", e); - return null; - } - }) - .filter(Predicate.not(Objects::isNull)); - } - - public CompletableFuture> deleteMessageByDestinationAndGuid( - final UUID destinationAccountUuid, final UUID messageUuid) { - - final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid); - final QueryRequest queryRequest = QueryRequest.builder() - .tableName(tableName) - .indexName(LOCAL_INDEX_MESSAGE_UUID_NAME) - .projectionExpression(KEY_SORT) - .consistentRead(true) - .keyConditionExpression("#part = :part AND #uuid = :uuid") - .expressionAttributeNames(Map.of( - "#part", KEY_PARTITION, - "#uuid", LOCAL_INDEX_MESSAGE_UUID_KEY_SORT)) - .expressionAttributeValues(Map.of( - ":part", partitionKey, - ":uuid", convertLocalIndexMessageUuidSortKey(messageUuid))) - .build(); - - // because we are filtering on message UUID, this query should return at most one item, - // but it’s simpler to handle the full stream and return the “last” item - return Flux.from(dbAsyncClient.queryPaginator(queryRequest).items()) - .flatMap(item -> Mono.fromCompletionStage(dbAsyncClient.deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, - AttributeValues.fromByteArray(item.get(KEY_SORT).b().asByteArray()))) - .returnValues(ReturnValue.ALL_OLD) - .build()))) - .mapNotNull(deleteItemResponse -> { - try { - if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) { - return convertItemToEnvelope(deleteItemResponse.attributes()); - } - } catch (final InvalidProtocolBufferException e) { - logger.error("Failed to parse envelope", e); - } - return null; - }) - .map(Optional::ofNullable) - .subscribeOn(messageDeletionScheduler) - .last(Optional.empty()) // if the flux is empty, last() will throw without a default - .toFuture(); - } - - public CompletableFuture> deleteMessage(final UUID destinationAccountUuid, - final long destinationDeviceId, final UUID messageUuid, final long serverTimestamp) { - - final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid); - final AttributeValue sortKey = convertSortKey(destinationDeviceId, serverTimestamp, messageUuid); - DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_PARTITION, partitionKey, KEY_SORT, sortKey)) - .returnValues(ReturnValue.ALL_OLD); - - return dbAsyncClient.deleteItem(deleteItemRequest.build()) - .thenApplyAsync(deleteItemResponse -> { - if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) { - try { - return Optional.of(convertItemToEnvelope(deleteItemResponse.attributes())); - } catch (final InvalidProtocolBufferException e) { - logger.error("Failed to parse envelope", e); - } - } - - return Optional.empty(); - }, messageDeletionExecutor); - } - - public void deleteAllMessagesForAccount(final UUID destinationAccountUuid) { - deleteByAccount.record(() -> { - final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid); - final QueryRequest queryRequest = QueryRequest.builder() - .tableName(tableName) - .projectionExpression(KEY_SORT) - .consistentRead(true) - .keyConditionExpression("#part = :part") - .expressionAttributeNames(Map.of("#part", KEY_PARTITION)) - .expressionAttributeValues(Map.of(":part", partitionKey)) - .build(); - deleteRowsMatchingQuery(partitionKey, queryRequest); - }); - } - - public void deleteAllMessagesForDevice(final UUID destinationAccountUuid, final long destinationDeviceId) { - deleteByDevice.record(() -> { - final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid); - final QueryRequest queryRequest = QueryRequest.builder() - .tableName(tableName) - .keyConditionExpression("#part = :part AND begins_with ( #sort , :sortprefix )") - .expressionAttributeNames(Map.of( - "#part", KEY_PARTITION, - "#sort", KEY_SORT)) - .expressionAttributeValues(Map.of( - ":part", partitionKey, - ":sortprefix", convertDestinationDeviceIdToSortKeyPrefix(destinationDeviceId))) - .projectionExpression(KEY_SORT) - .consistentRead(true) - .build(); - deleteRowsMatchingQuery(partitionKey, queryRequest); - }); - } - - @VisibleForTesting - static MessageProtos.Envelope convertItemToEnvelope(final Map item) - throws InvalidProtocolBufferException { - - return MessageProtos.Envelope.parseFrom(item.get(KEY_ENVELOPE_BYTES).b().asByteArray()); - } - - private void deleteRowsMatchingQuery(AttributeValue partitionKey, QueryRequest querySpec) { - writeInBatches(db().queryPaginator(querySpec).items(), itemBatch -> deleteItems(partitionKey, itemBatch)); - } - - private void deleteItems(AttributeValue partitionKey, List> items) { - List deletes = items.stream() - .map(item -> WriteRequest.builder() - .deleteRequest(DeleteRequest.builder().key(Map.of( - KEY_PARTITION, partitionKey, - KEY_SORT, item.get(KEY_SORT))).build()) - .build()) - .toList(); - executeTableWriteItemsUntilComplete(Map.of(tableName, deletes)); - } - - private long getTtlForMessage(MessageProtos.Envelope message) { - return message.getServerTimestamp() / 1000 + timeToLive.getSeconds(); - } - - private static AttributeValue convertPartitionKey(final UUID destinationAccountUuid) { - return AttributeValues.fromUUID(destinationAccountUuid); - } - - private static AttributeValue convertSortKey(final long destinationDeviceId, final long serverTimestamp, final UUID messageUuid) { - ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[32]); - byteBuffer.putLong(destinationDeviceId); - byteBuffer.putLong(serverTimestamp); - byteBuffer.putLong(messageUuid.getMostSignificantBits()); - byteBuffer.putLong(messageUuid.getLeastSignificantBits()); - return AttributeValues.fromByteBuffer(byteBuffer.flip()); - } - - private static AttributeValue convertDestinationDeviceIdToSortKeyPrefix(final long destinationDeviceId) { - ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[8]); - byteBuffer.putLong(destinationDeviceId); - return AttributeValues.fromByteBuffer(byteBuffer.flip()); - } - - private static AttributeValue convertLocalIndexMessageUuidSortKey(final UUID messageUuid) { - return AttributeValues.fromUUID(messageUuid); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java deleted file mode 100644 index de50f8680..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.stream.Collectors; -import javax.annotation.Nullable; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.Pair; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public class MessagesManager { - - private static final int RESULT_SET_CHUNK_SIZE = 100; - final String GET_MESSAGES_FOR_DEVICE_FLUX_NAME = MetricsUtil.name(MessagesManager.class, "getMessagesForDevice"); - - private static final Logger logger = LoggerFactory.getLogger(MessagesManager.class); - - private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private static final Meter cacheHitByGuidMeter = metricRegistry.meter(name(MessagesManager.class, "cacheHitByGuid")); - private static final Meter cacheMissByGuidMeter = metricRegistry.meter( - name(MessagesManager.class, "cacheMissByGuid")); - private static final Meter persistMessageMeter = metricRegistry.meter(name(MessagesManager.class, "persistMessage")); - - private final MessagesDynamoDb messagesDynamoDb; - private final MessagesCache messagesCache; - private final ReportMessageManager reportMessageManager; - private final ExecutorService messageDeletionExecutor; - - public MessagesManager( - final MessagesDynamoDb messagesDynamoDb, - final MessagesCache messagesCache, - final ReportMessageManager reportMessageManager, - final ExecutorService messageDeletionExecutor) { - this.messagesDynamoDb = messagesDynamoDb; - this.messagesCache = messagesCache; - this.reportMessageManager = reportMessageManager; - this.messageDeletionExecutor = messageDeletionExecutor; - } - - public void insert(UUID destinationUuid, long destinationDevice, Envelope message) { - final UUID messageGuid = UUID.randomUUID(); - - messagesCache.insert(messageGuid, destinationUuid, destinationDevice, message); - - if (message.hasSourceUuid() && !destinationUuid.toString().equals(message.getSourceUuid())) { - reportMessageManager.store(message.getSourceUuid(), messageGuid); - } - } - - public boolean hasCachedMessages(final UUID destinationUuid, final long destinationDevice) { - return messagesCache.hasMessages(destinationUuid, destinationDevice); - } - - public Mono, Boolean>> getMessagesForDevice(UUID destinationUuid, long destinationDevice, - boolean cachedMessagesOnly) { - - return Flux.from( - getMessagesForDevice(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE, cachedMessagesOnly)) - .take(RESULT_SET_CHUNK_SIZE, true) - .collectList() - .map(envelopes -> new Pair<>(envelopes, envelopes.size() >= RESULT_SET_CHUNK_SIZE)); - } - - public Publisher getMessagesForDeviceReactive(UUID destinationUuid, long destinationDevice, - final boolean cachedMessagesOnly) { - - return getMessagesForDevice(destinationUuid, destinationDevice, null, cachedMessagesOnly); - } - - private Publisher getMessagesForDevice(UUID destinationUuid, long destinationDevice, - @Nullable Integer limit, final boolean cachedMessagesOnly) { - - final Publisher dynamoPublisher = - cachedMessagesOnly ? Flux.empty() : messagesDynamoDb.load(destinationUuid, destinationDevice, limit); - final Publisher cachePublisher = messagesCache.get(destinationUuid, destinationDevice); - - return Flux.concat(dynamoPublisher, cachePublisher) - .name(GET_MESSAGES_FOR_DEVICE_FLUX_NAME) - .metrics(); - } - - public void clear(UUID destinationUuid) { - messagesCache.clear(destinationUuid); - messagesDynamoDb.deleteAllMessagesForAccount(destinationUuid); - } - - public void clear(UUID destinationUuid, long deviceId) { - messagesCache.clear(destinationUuid, deviceId); - messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, deviceId); - } - - public CompletableFuture> delete(UUID destinationUuid, long destinationDeviceId, UUID guid, - @Nullable Long serverTimestamp) { - return messagesCache.remove(destinationUuid, destinationDeviceId, guid) - .thenComposeAsync(removed -> { - - if (removed.isPresent()) { - cacheHitByGuidMeter.mark(); - return CompletableFuture.completedFuture(removed); - } - - cacheMissByGuidMeter.mark(); - - if (serverTimestamp == null) { - return messagesDynamoDb.deleteMessageByDestinationAndGuid(destinationUuid, guid); - } else { - return messagesDynamoDb.deleteMessage(destinationUuid, destinationDeviceId, guid, serverTimestamp); - } - - }, messageDeletionExecutor); - } - - /** - * @return the number of messages successfully removed from the cache. - */ - public int persistMessages( - final UUID destinationUuid, - final long destinationDeviceId, - final List messages) { - - final List nonEphemeralMessages = messages.stream() - .filter(envelope -> !envelope.getEphemeral()) - .collect(Collectors.toList()); - - messagesDynamoDb.store(nonEphemeralMessages, destinationUuid, destinationDeviceId); - - final List messageGuids = messages.stream().map(message -> UUID.fromString(message.getServerGuid())) - .collect(Collectors.toList()); - int messagesRemovedFromCache = 0; - try { - messagesRemovedFromCache = messagesCache.remove(destinationUuid, destinationDeviceId, messageGuids) - .get(30, TimeUnit.SECONDS).size(); - persistMessageMeter.mark(nonEphemeralMessages.size()); - - } catch (InterruptedException | ExecutionException | TimeoutException e) { - logger.warn("Failed to remove messages from cache", e); - } - return messagesRemovedFromCache; - } - - public void addMessageAvailabilityListener( - final UUID destinationUuid, - final long destinationDeviceId, - final MessageAvailabilityListener listener) { - messagesCache.addMessageAvailabilityListener(destinationUuid, destinationDeviceId, listener); - } - - public void removeMessageAvailabilityListener(final MessageAvailabilityListener listener) { - messagesCache.removeMessageAvailabilityListener(listener); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/NonNormalizedAccountCrawlerListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/NonNormalizedAccountCrawlerListener.java deleted file mode 100644 index bc69e9904..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/NonNormalizedAccountCrawlerListener.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.google.common.annotations.VisibleForTesting; -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; -import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public class NonNormalizedAccountCrawlerListener extends AccountDatabaseCrawlerListener { - - private final AccountsManager accountsManager; - private final FaultTolerantRedisCluster metricsCluster; - - private static final String NORMALIZED_NUMBER_COUNT_KEY = "NonNormalizedAccountCrawlerListener::normalized"; - private static final String NON_NORMALIZED_NUMBER_COUNT_KEY = "NonNormalizedAccountCrawlerListener::nonNormalized"; - private static final String CONFLICTING_NUMBER_COUNT_KEY = "NonNormalizedAccountCrawlerListener::conflicts"; - - private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance(); - - private static final Logger log = LoggerFactory.getLogger(NonNormalizedAccountCrawlerListener.class); - - public NonNormalizedAccountCrawlerListener( - final AccountsManager accountsManager, - final FaultTolerantRedisCluster metricsCluster) { - - this.accountsManager = accountsManager; - this.metricsCluster = metricsCluster; - } - - @Override - public void onCrawlStart() { - metricsCluster.useCluster(connection -> - connection.sync().del(NORMALIZED_NUMBER_COUNT_KEY, NON_NORMALIZED_NUMBER_COUNT_KEY, CONFLICTING_NUMBER_COUNT_KEY)); - } - - @Override - protected void onCrawlChunk(final Optional fromUuid, final List chunkAccounts) { - - final int normalizedNumbers; - final int nonNormalizedNumbers; - final int conflictingNumbers; - { - int workingNormalizedNumbers = 0; - int workingNonNormalizedNumbers = 0; - int workingConflictingNumbers = 0; - - for (final Account account : chunkAccounts) { - if (hasNumberNormalized(account)) { - workingNormalizedNumbers++; - } else { - workingNonNormalizedNumbers++; - - try { - final Optional maybeConflictingAccount = accountsManager.getByE164(getNormalizedNumber(account)); - - if (maybeConflictingAccount.isPresent()) { - workingConflictingNumbers++; - log.info("Normalized form of number for account {} conflicts with number for account {}", - account.getUuid(), maybeConflictingAccount.get().getUuid()); - } - } catch (final NumberParseException e) { - log.warn("Failed to parse phone number for account {}", account.getUuid(), e); - } - } - } - - normalizedNumbers = workingNormalizedNumbers; - nonNormalizedNumbers = workingNonNormalizedNumbers; - conflictingNumbers = workingConflictingNumbers; - } - - metricsCluster.useCluster(connection -> { - connection.sync().incrby(NORMALIZED_NUMBER_COUNT_KEY, normalizedNumbers); - connection.sync().incrby(NON_NORMALIZED_NUMBER_COUNT_KEY, nonNormalizedNumbers); - connection.sync().incrby(CONFLICTING_NUMBER_COUNT_KEY, conflictingNumbers); - }); - } - - @Override - public void onCrawlEnd(final Optional fromUuid) { - final int normalizedNumbers = metricsCluster.withCluster(connection -> - Integer.parseInt(connection.sync().get(NORMALIZED_NUMBER_COUNT_KEY))); - - final int nonNormalizedNumbers = metricsCluster.withCluster(connection -> - Integer.parseInt(connection.sync().get(NON_NORMALIZED_NUMBER_COUNT_KEY))); - - final int conflictingNumbers = metricsCluster.withCluster(connection -> - Integer.parseInt(connection.sync().get(CONFLICTING_NUMBER_COUNT_KEY))); - - log.info("Crawl completed. Normalized numbers: {}; non-normalized numbers: {}; conflicting numbers: {}", - normalizedNumbers, nonNormalizedNumbers, conflictingNumbers); - } - - @VisibleForTesting - static boolean hasNumberNormalized(final Account account) { - try { - return account.getNumber().equals(getNormalizedNumber(account)); - } catch (final NumberParseException e) { - log.warn("Failed to parse phone number for account {}", account.getUuid(), e); - return false; - } - } - - private static String getNormalizedNumber(final Account account) throws NumberParseException { - final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(account.getNumber(), null); - return PHONE_NUMBER_UTIL.format(phoneNumber, PhoneNumberFormat.E164); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/OptimisticLockRetryLimitExceededException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/OptimisticLockRetryLimitExceededException.java deleted file mode 100644 index 1e608ed09..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/OptimisticLockRetryLimitExceededException.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -public class OptimisticLockRetryLimitExceededException extends RuntimeException { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java deleted file mode 100644 index 384b7c71f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.QueryResponse; -import software.amazon.awssdk.services.dynamodb.model.ReturnValue; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse; - -/** - * Manages a global, persistent mapping of phone numbers to phone number identifiers regardless of whether those - * numbers/identifiers are actually associated with an account. - */ -public class PhoneNumberIdentifiers { - - private final DynamoDbClient dynamoDbClient; - private final String tableName; - - @VisibleForTesting - static final String KEY_E164 = "P"; - @VisibleForTesting - static final String INDEX_NAME = "pni_to_p"; - @VisibleForTesting - static final String ATTR_PHONE_NUMBER_IDENTIFIER = "PNI"; - - private static final Timer GET_PNI_TIMER = Metrics.timer(name(PhoneNumberIdentifiers.class, "get")); - private static final Timer SET_PNI_TIMER = Metrics.timer(name(PhoneNumberIdentifiers.class, "set")); - - public PhoneNumberIdentifiers(final DynamoDbClient dynamoDbClient, final String tableName) { - this.dynamoDbClient = dynamoDbClient; - this.tableName = tableName; - } - - /** - * Returns the phone number identifier (PNI) associated with the given phone number. - * - * @param phoneNumber the phone number for which to retrieve a phone number identifier - * @return the phone number identifier associated with the given phone number - */ - public UUID getPhoneNumberIdentifier(final String phoneNumber) { - final GetItemResponse response = GET_PNI_TIMER.record(() -> dynamoDbClient.getItem(GetItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_E164, AttributeValues.fromString(phoneNumber))) - .projectionExpression(ATTR_PHONE_NUMBER_IDENTIFIER) - .build())); - - final UUID phoneNumberIdentifier; - - if (response.hasItem()) { - phoneNumberIdentifier = AttributeValues.getUUID(response.item(), ATTR_PHONE_NUMBER_IDENTIFIER, null); - } else { - phoneNumberIdentifier = generatePhoneNumberIdentifierIfNotExists(phoneNumber); - } - - if (phoneNumberIdentifier == null) { - throw new RuntimeException("Could not retrieve phone number identifier from stored item"); - } - - return phoneNumberIdentifier; - } - - public Optional getPhoneNumber(final UUID phoneNumberIdentifier) { - final QueryResponse response = dynamoDbClient.query(QueryRequest.builder() - .tableName(tableName) - .indexName(INDEX_NAME) - .keyConditionExpression("#pni = :pni") - .projectionExpression("#phone_number") - .expressionAttributeNames(Map.of( - "#phone_number", KEY_E164, - "#pni", ATTR_PHONE_NUMBER_IDENTIFIER - )) - .expressionAttributeValues(Map.of( - ":pni", AttributeValues.fromUUID(phoneNumberIdentifier) - )) - .build()); - - if (response.count() == 0) { - return Optional.empty(); - } - - if (response.count() > 1) { - throw new RuntimeException( - "Impossible result: more than one phone number returned for PNI: " + phoneNumberIdentifier); - } - - return Optional.ofNullable(response.items().get(0).get(KEY_E164).s()); - } - - - @VisibleForTesting - UUID generatePhoneNumberIdentifierIfNotExists(final String phoneNumber) { - final UpdateItemResponse response = SET_PNI_TIMER.record(() -> dynamoDbClient.updateItem(UpdateItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_E164, AttributeValues.fromString(phoneNumber))) - .updateExpression("SET #pni = if_not_exists(#pni, :pni)") - .expressionAttributeNames(Map.of("#pni", ATTR_PHONE_NUMBER_IDENTIFIER)) - .expressionAttributeValues(Map.of(":pni", AttributeValues.fromUUID(UUID.randomUUID()))) - .returnValues(ReturnValue.ALL_NEW) - .build())); - - return AttributeValues.getUUID(response.attributes(), ATTR_PHONE_NUMBER_IDENTIFIER, null); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java deleted file mode 100644 index 6e1d47cfe..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; -import software.amazon.awssdk.services.dynamodb.paginators.QueryIterable; - -public class Profiles { - - private final DynamoDbClient dynamoDbClient; - private final DynamoDbAsyncClient dynamoDbAsyncClient; - private final String tableName; - - // UUID of the account that owns this profile; byte array - @VisibleForTesting - static final String KEY_ACCOUNT_UUID = "U"; - - // Version of this profile; string - @VisibleForTesting - static final String ATTR_VERSION = "V"; - - // User's name; string - private static final String ATTR_NAME = "N"; - - // Avatar path/filename; string - private static final String ATTR_AVATAR = "A"; - - // Bio/about text; string - private static final String ATTR_ABOUT = "B"; - - // Bio/about emoji; string - private static final String ATTR_EMOJI = "E"; - - // Payment address; string - private static final String ATTR_PAYMENT_ADDRESS = "P"; - - // Commitment; byte array - private static final String ATTR_COMMITMENT = "C"; - - private static final Map UPDATE_EXPRESSION_ATTRIBUTE_NAMES = Map.of( - "#commitment", ATTR_COMMITMENT, - "#name", ATTR_NAME, - "#avatar", ATTR_AVATAR, - "#about", ATTR_ABOUT, - "#aboutEmoji", ATTR_EMOJI, - "#paymentAddress", ATTR_PAYMENT_ADDRESS); - - private static final Timer SET_PROFILES_TIMER = Metrics.timer(name(Profiles.class, "set")); - private static final Timer GET_PROFILE_TIMER = Metrics.timer(name(Profiles.class, "get")); - private static final Timer DELETE_PROFILES_TIMER = Metrics.timer(name(Profiles.class, "delete")); - - public Profiles(final DynamoDbClient dynamoDbClient, - final DynamoDbAsyncClient dynamoDbAsyncClient, - final String tableName) { - - this.dynamoDbClient = dynamoDbClient; - this.dynamoDbAsyncClient = dynamoDbAsyncClient; - this.tableName = tableName; - } - - public void set(final UUID uuid, final VersionedProfile profile) { - SET_PROFILES_TIMER.record(() -> { - dynamoDbClient.updateItem(UpdateItemRequest.builder() - .tableName(tableName) - .key(buildPrimaryKey(uuid, profile.getVersion())) - .updateExpression(buildUpdateExpression(profile)) - .expressionAttributeNames(UPDATE_EXPRESSION_ATTRIBUTE_NAMES) - .expressionAttributeValues(buildUpdateExpressionAttributeValues(profile)) - .build()); - }); - } - - private static Map buildPrimaryKey(final UUID uuid, final String version) { - return Map.of( - KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid), - ATTR_VERSION, AttributeValues.fromString(version)); - } - - @VisibleForTesting - static String buildUpdateExpression(final VersionedProfile profile) { - final List updatedAttributes = new ArrayList<>(5); - final List deletedAttributes = new ArrayList<>(5); - - if (StringUtils.isNotBlank(profile.getName())) { - updatedAttributes.add("name"); - } else { - deletedAttributes.add("name"); - } - - if (StringUtils.isNotBlank(profile.getAvatar())) { - updatedAttributes.add("avatar"); - } else { - deletedAttributes.add("avatar"); - } - - if (StringUtils.isNotBlank(profile.getAbout())) { - updatedAttributes.add("about"); - } else { - deletedAttributes.add("about"); - } - - if (StringUtils.isNotBlank(profile.getAboutEmoji())) { - updatedAttributes.add("aboutEmoji"); - } else { - deletedAttributes.add("aboutEmoji"); - } - - if (StringUtils.isNotBlank(profile.getPaymentAddress())) { - updatedAttributes.add("paymentAddress"); - } else { - deletedAttributes.add("paymentAddress"); - } - - final StringBuilder updateExpressionBuilder = new StringBuilder( - "SET #commitment = if_not_exists(#commitment, :commitment)"); - - if (!updatedAttributes.isEmpty()) { - updatedAttributes.forEach(token -> updateExpressionBuilder - .append(", #") - .append(token) - .append(" = :") - .append(token)); - } - - if (!deletedAttributes.isEmpty()) { - updateExpressionBuilder.append(" REMOVE "); - updateExpressionBuilder.append(deletedAttributes.stream() - .map(token -> "#" + token) - .collect(Collectors.joining(", "))); - } - - return updateExpressionBuilder.toString(); - } - - @VisibleForTesting - static Map buildUpdateExpressionAttributeValues(final VersionedProfile profile) { - final Map expressionValues = new HashMap<>(); - - expressionValues.put(":commitment", AttributeValues.fromByteArray(profile.getCommitment())); - - if (StringUtils.isNotBlank(profile.getName())) { - expressionValues.put(":name", AttributeValues.fromString(profile.getName())); - } - - if (StringUtils.isNotBlank(profile.getAvatar())) { - expressionValues.put(":avatar", AttributeValues.fromString(profile.getAvatar())); - } - - if (StringUtils.isNotBlank(profile.getAbout())) { - expressionValues.put(":about", AttributeValues.fromString(profile.getAbout())); - } - - if (StringUtils.isNotBlank(profile.getAboutEmoji())) { - expressionValues.put(":aboutEmoji", AttributeValues.fromString(profile.getAboutEmoji())); - } - - if (StringUtils.isNotBlank(profile.getPaymentAddress())) { - expressionValues.put(":paymentAddress", AttributeValues.fromString(profile.getPaymentAddress())); - } - - return expressionValues; - } - - public Optional get(final UUID uuid, final String version) { - return GET_PROFILE_TIMER.record(() -> { - final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder() - .tableName(tableName) - .key(buildPrimaryKey(uuid, version)) - .consistentRead(true) - .build()); - - return response.hasItem() ? Optional.of(fromItem(response.item())) : Optional.empty(); - }); - } - - private static VersionedProfile fromItem(final Map item) { - return new VersionedProfile( - AttributeValues.getString(item, ATTR_VERSION, null), - AttributeValues.getString(item, ATTR_NAME, null), - AttributeValues.getString(item, ATTR_AVATAR, null), - AttributeValues.getString(item, ATTR_EMOJI, null), - AttributeValues.getString(item, ATTR_ABOUT, null), - AttributeValues.getString(item, ATTR_PAYMENT_ADDRESS, null), - AttributeValues.getByteArray(item, ATTR_COMMITMENT, null)); - } - - public void deleteAll(final UUID uuid) { - DELETE_PROFILES_TIMER.record(() -> { - final AttributeValue uuidAttributeValue = AttributeValues.fromUUID(uuid); - - final QueryIterable queryIterable = dynamoDbClient.queryPaginator(QueryRequest.builder() - .tableName(tableName) - .keyConditionExpression("#uuid = :uuid") - .expressionAttributeNames(Map.of("#uuid", KEY_ACCOUNT_UUID)) - .expressionAttributeValues(Map.of(":uuid", uuidAttributeValue)) - .projectionExpression(ATTR_VERSION) - .consistentRead(true) - .build()); - - CompletableFuture.allOf(queryIterable.items().stream() - .map(item -> dynamoDbAsyncClient.deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of( - KEY_ACCOUNT_UUID, uuidAttributeValue, - ATTR_VERSION, item.get(ATTR_VERSION))) - .build())) - .toArray(CompletableFuture[]::new)).join(); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java deleted file mode 100644 index cf993c084..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.lettuce.core.RedisException; -import java.io.IOException; -import java.util.Optional; -import java.util.UUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -public class ProfilesManager { - - private final Logger logger = LoggerFactory.getLogger(ProfilesManager.class); - - private static final String CACHE_PREFIX = "profiles::"; - - private final Profiles profiles; - private final FaultTolerantRedisCluster cacheCluster; - private final ObjectMapper mapper; - - public ProfilesManager(final Profiles profiles, - final FaultTolerantRedisCluster cacheCluster) { - this.profiles = profiles; - this.cacheCluster = cacheCluster; - this.mapper = SystemMapper.getMapper(); - } - - public void set(UUID uuid, VersionedProfile versionedProfile) { - memcacheSet(uuid, versionedProfile); - profiles.set(uuid, versionedProfile); - } - - public void deleteAll(UUID uuid) { - memcacheDelete(uuid); - profiles.deleteAll(uuid); - } - - public Optional get(UUID uuid, String version) { - Optional profile = memcacheGet(uuid, version); - - if (profile.isEmpty()) { - profile = profiles.get(uuid, version); - profile.ifPresent(versionedProfile -> memcacheSet(uuid, versionedProfile)); - } - - return profile; - } - - private void memcacheSet(UUID uuid, VersionedProfile profile) { - try { - final String profileJson = mapper.writeValueAsString(profile); - - cacheCluster.useCluster(connection -> connection.sync().hset(CACHE_PREFIX + uuid.toString(), profile.getVersion(), profileJson)); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(e); - } - } - - private Optional memcacheGet(UUID uuid, String version) { - try { - final String json = cacheCluster.withCluster(connection -> connection.sync().hget(CACHE_PREFIX + uuid.toString(), version)); - - if (json == null) return Optional.empty(); - else return Optional.of(mapper.readValue(json, VersionedProfile.class)); - } catch (IOException e) { - logger.warn("Error deserializing value...", e); - return Optional.empty(); - } catch (RedisException e) { - logger.warn("Redis exception", e); - return Optional.empty(); - } - } - - private void memcacheDelete(UUID uuid) { - cacheCluster.useCluster(connection -> connection.sync().del(CACHE_PREFIX + uuid.toString())); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProhibitedUsernames.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProhibitedUsernames.java deleted file mode 100644 index c44cdb98c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ProhibitedUsernames.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.CacheLoader; -import com.google.common.cache.LoadingCache; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.util.Map; -import java.util.UUID; -import java.util.regex.Pattern; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import software.amazon.awssdk.services.dynamodb.model.ScanResponse; -import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable; - -public class ProhibitedUsernames { - - private final DynamoDbClient dynamoDbClient; - private final String tableName; - - private final LoadingCache patternCache = CacheBuilder.newBuilder() - .maximumSize(1_000) - .build(new CacheLoader<>() { - @Override - public Pattern load(final String s) { - return Pattern.compile(s, Pattern.CASE_INSENSITIVE); - } - }); - - @VisibleForTesting - static final String KEY_PATTERN = "P"; - private static final String ATTR_RESERVED_FOR_UUID = "U"; - - private static final Timer IS_PROHIBITED_TIMER = Metrics.timer(name(ProhibitedUsernames.class, "isProhibited")); - - private static final Logger log = LoggerFactory.getLogger(ProhibitedUsernames.class); - - public ProhibitedUsernames(final DynamoDbClient dynamoDbClient, final String tableName) { - this.dynamoDbClient = dynamoDbClient; - this.tableName = tableName; - } - - public boolean isProhibited(final String nickname, final UUID accountIdentifier) { - return IS_PROHIBITED_TIMER.record(() -> { - final ScanIterable scanIterable = dynamoDbClient.scanPaginator(ScanRequest.builder() - .tableName(tableName) - .build()); - - for (final ScanResponse scanResponse : scanIterable) { - if (scanResponse.hasItems()) { - for (final Map item : scanResponse.items()) { - try { - final Pattern pattern = patternCache.get(item.get(KEY_PATTERN).s()); - final UUID reservedFor = AttributeValues.getUUID(item, ATTR_RESERVED_FOR_UUID, null); - - if (pattern.matcher(nickname).matches() && !accountIdentifier.equals(reservedFor)) { - return true; - } - } catch (final Exception e) { - log.error("Failed to load pattern from item: {}", item, e); - } - } - } - } - - return false; - }); - } - - /** - * Prohibits username except for all accounts except `reservedFor` - * - * @param pattern pattern to prohibit - * @param reservedFor an account that is allowed to use names in the pattern - */ - public void prohibitUsername(final String pattern, final UUID reservedFor) { - dynamoDbClient.putItem(PutItemRequest.builder() - .tableName(tableName) - .item(Map.of( - KEY_PATTERN, AttributeValues.fromString(pattern), - ATTR_RESERVED_FOR_UUID, AttributeValues.fromUUID(reservedFor))) - .build()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PubSubAddress.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PubSubAddress.java deleted file mode 100644 index f63ea86ff..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PubSubAddress.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -public interface PubSubAddress { - - String serialize(); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PubSubManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PubSubManager.java deleted file mode 100644 index e2ba58d92..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PubSubManager.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.dispatch.DispatchChannel; -import org.whispersystems.dispatch.DispatchManager; -import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool; - -import io.dropwizard.lifecycle.Managed; -import static org.whispersystems.textsecuregcm.storage.PubSubProtos.PubSubMessage; -import redis.clients.jedis.Jedis; - -public class PubSubManager implements Managed { - - private static final String KEEPALIVE_CHANNEL = "KEEPALIVE"; - - private final Logger logger = LoggerFactory.getLogger(PubSubManager.class); - - private final DispatchManager dispatchManager; - private final ReplicatedJedisPool jedisPool; - - private boolean subscribed = false; - - public PubSubManager(ReplicatedJedisPool jedisPool, DispatchManager dispatchManager) { - this.dispatchManager = dispatchManager; - this.jedisPool = jedisPool; - } - - @Override - public void start() throws Exception { - this.dispatchManager.start(); - - KeepaliveDispatchChannel keepaliveDispatchChannel = new KeepaliveDispatchChannel(); - this.dispatchManager.subscribe(KEEPALIVE_CHANNEL, keepaliveDispatchChannel); - - synchronized (this) { - while (!subscribed) wait(0); - } - - new KeepaliveSender().start(); - } - - @Override - public void stop() throws Exception { - dispatchManager.shutdown(); - } - - public void subscribe(PubSubAddress address, DispatchChannel channel) { - dispatchManager.subscribe(address.serialize(), channel); - } - - public void unsubscribe(PubSubAddress address, DispatchChannel dispatchChannel) { - dispatchManager.unsubscribe(address.serialize(), dispatchChannel); - } - - public boolean hasLocalSubscription(PubSubAddress address) { - return dispatchManager.hasSubscription(address.serialize()); - } - - public boolean publish(PubSubAddress address, PubSubMessage message) { - return publish(address.serialize().getBytes(), message); - } - - private boolean publish(byte[] channel, PubSubMessage message) { - try (Jedis jedis = jedisPool.getWriteResource()) { - long result = jedis.publish(channel, message.toByteArray()); - - if (result < 0) { - logger.warn("**** Jedis publish result < 0"); - } - - return result > 0; - } - } - - private class KeepaliveDispatchChannel implements DispatchChannel { - - @Override - public void onDispatchMessage(String channel, byte[] message) { - // Good - } - - @Override - public void onDispatchSubscribed(String channel) { - if (KEEPALIVE_CHANNEL.equals(channel)) { - synchronized (PubSubManager.this) { - subscribed = true; - PubSubManager.this.notifyAll(); - } - } - } - - @Override - public void onDispatchUnsubscribed(String channel) { - logger.warn("***** KEEPALIVE CHANNEL UNSUBSCRIBED *****"); - } - } - - private class KeepaliveSender extends Thread { - @Override - public void run() { - while (true) { - try { - Thread.sleep(20000); - publish(KEEPALIVE_CHANNEL.getBytes(), PubSubMessage.newBuilder() - .setType(PubSubMessage.Type.KEEPALIVE) - .build()); - } catch (Throwable e) { - logger.warn("***** KEEPALIVE EXCEPTION ******", e); - } - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDb.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDb.java deleted file mode 100644 index 2ad0600d6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDb.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.google.common.annotations.VisibleForTesting; -import java.time.Clock; -import java.time.Duration; -import java.util.Map; -import java.util.UUID; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; - -/** - * Stores push challenge tokens. Users may have at most one outstanding push challenge token at a time. - */ -public class PushChallengeDynamoDb extends AbstractDynamoDbStore { - - private final String tableName; - private final Clock clock; - - static final String KEY_ACCOUNT_UUID = "U"; - static final String ATTR_CHALLENGE_TOKEN = "C"; - static final String ATTR_TTL = "T"; - - private static final Map UUID_NAME_MAP = Map.of("#uuid", KEY_ACCOUNT_UUID); - private static final Map CHALLENGE_TOKEN_NAME_MAP = Map.of("#challenge", ATTR_CHALLENGE_TOKEN, "#ttl", - ATTR_TTL); - - public PushChallengeDynamoDb(final DynamoDbClient dynamoDB, final String tableName) { - this(dynamoDB, tableName, Clock.systemUTC()); - } - - @VisibleForTesting - PushChallengeDynamoDb(final DynamoDbClient dynamoDB, final String tableName, final Clock clock) { - super(dynamoDB); - - this.tableName = tableName; - this.clock = clock; - } - - /** - * Stores a push challenge token for the given user if and only if the user doesn't already have a token stored. The - * existence check is strongly-consistent. - * - * @param accountUuid the UUID of the account for which to store a push challenge token - * @param challengeToken the challenge token itself - * @param ttl the time after which the token is no longer valid - * @return {@code true} if a new token was stored of {@code false} if another token already exists for the given - * account - */ - public boolean add(final UUID accountUuid, final byte[] challengeToken, final Duration ttl) { - try { - db().putItem(PutItemRequest.builder() - .tableName(tableName) - .item(Map.of( - KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountUuid), - ATTR_CHALLENGE_TOKEN, AttributeValues.fromByteArray(challengeToken), - ATTR_TTL, AttributeValues.fromLong(getExpirationTimestamp(ttl)))) - .conditionExpression("attribute_not_exists(#uuid)") - .expressionAttributeNames(UUID_NAME_MAP) - .build()); - return true; - } catch (final ConditionalCheckFailedException e) { - return false; - } - } - - long getExpirationTimestamp(final Duration ttl) { - return clock.instant().plus(ttl).getEpochSecond(); - } - - /** - * Clears a push challenge token for the given user if and only if the given challenge token matches the stored token. - * The token comparison is a strongly-consistent operation. - * - * @param accountUuid the account for which to remove a stored token - * @param challengeToken the token to remove - * @return {@code true} if the given token matched the stored token for the given user or {@code false} otherwise - */ - public boolean remove(final UUID accountUuid, final byte[] challengeToken) { - try { - db().deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountUuid))) - .conditionExpression("#challenge = :challenge AND #ttl >= :currentTime") - .expressionAttributeNames(CHALLENGE_TOKEN_NAME_MAP) - .expressionAttributeValues(Map.of(":challenge", AttributeValues.fromByteArray(challengeToken), - ":currentTime", AttributeValues.fromLong(clock.instant().getEpochSecond()))) - .build()); - return true; - } catch (final ConditionalCheckFailedException e) { - return false; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PushFeedbackProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PushFeedbackProcessor.java deleted file mode 100644 index 0837032c8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PushFeedbackProcessor.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.Util; - -public class PushFeedbackProcessor extends AccountDatabaseCrawlerListener { - - private final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private final Meter expired = metricRegistry.meter(name(getClass(), "unregistered", "expired")); - private final Meter recovered = metricRegistry.meter(name(getClass(), "unregistered", "recovered")); - - private final AccountsManager accountsManager; - - public PushFeedbackProcessor(AccountsManager accountsManager) { - this.accountsManager = accountsManager; - } - - @Override - public void onCrawlStart() {} - - @Override - public void onCrawlEnd(Optional toUuid) {} - - @Override - protected void onCrawlChunk(Optional fromUuid, List chunkAccounts) { - for (Account account : chunkAccounts) { - boolean update = false; - - for (Device device : account.getDevices()) { - if (deviceNeedsUpdate(device)) { - if (deviceExpired(device)) { - if (device.isEnabled()) { - expired.mark(); - update = true; - } - } else { - recovered.mark(); - update = true; - } - } - } - - if (update) { - // fetch a new version, since the chunk is shared and implicitly read-only - accountsManager.getByAccountIdentifier(account.getUuid()).ifPresent(accountToUpdate -> { - accountsManager.update(accountToUpdate, a -> { - for (Device device : a.getDevices()) { - if (deviceNeedsUpdate(device)) { - if (deviceExpired(device)) { - if (!Util.isEmpty(device.getApnId())) { - if (device.getId() == 1) { - device.setUserAgent("OWI"); - } else { - device.setUserAgent("OWP"); - } - } else if (!Util.isEmpty(device.getGcmId())) { - device.setUserAgent("OWA"); - } - device.setGcmId(null); - device.setApnId(null); - device.setVoipApnId(null); - device.setFetchesMessages(false); - } else { - device.setUninstalledFeedbackTimestamp(0); - } - } - } - }); - }); - } - } - } - - private boolean deviceNeedsUpdate(final Device device) { - return device.getUninstalledFeedbackTimestamp() != 0 && - device.getUninstalledFeedbackTimestamp() + TimeUnit.DAYS.toMillis(2) <= Util.todayInMillis(); - } - - private boolean deviceExpired(final Device device) { - return device.getLastSeen() + TimeUnit.DAYS.toMillis(2) <= Util.todayInMillis(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java deleted file mode 100644 index b59ee48d6..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import javax.annotation.Nonnull; -import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.UUIDUtil; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ReturnValue; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -public class RedeemedReceiptsManager { - - public static final String KEY_SERIAL = "S"; - public static final String KEY_TTL = "E"; - public static final String KEY_RECEIPT_EXPIRATION = "G"; - public static final String KEY_RECEIPT_LEVEL = "L"; - public static final String KEY_ACCOUNT_UUID = "U"; - public static final String KEY_REDEMPTION_TIME = "R"; - - private final Clock clock; - private final String table; - private final DynamoDbAsyncClient client; - private final Duration expirationTime; - - public RedeemedReceiptsManager( - @Nonnull final Clock clock, - @Nonnull final String table, - @Nonnull final DynamoDbAsyncClient client, - @Nonnull final Duration expirationTime) { - this.clock = Objects.requireNonNull(clock); - this.table = Objects.requireNonNull(table); - this.client = Objects.requireNonNull(client); - this.expirationTime = Objects.requireNonNull(expirationTime); - } - - /** - * Returns true either if it's able to insert a new redeemed receipt entry with the {@code receiptExpiration}, {@code - * receiptLevel}, and {@code accountUuid} provided or if an existing entry already exists with the same values thereby - * allowing idempotent request processing. - */ - public CompletableFuture put( - @Nonnull final ReceiptSerial receiptSerial, - final long receiptExpiration, - final long receiptLevel, - @Nonnull final UUID accountUuid) { - - // fail early if given bad inputs - Objects.requireNonNull(receiptSerial); - Objects.requireNonNull(accountUuid); - - final Instant now = clock.instant(); - final Instant rowExpiration = now.plus(expirationTime); - final AttributeValue serialAttributeValue = AttributeValues.b(receiptSerial.serialize()); - - final UpdateItemRequest updateItemRequest = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_SERIAL, serialAttributeValue)) - .returnValues(ReturnValue.ALL_NEW) - .updateExpression("SET #ttl = if_not_exists(#ttl, :ttl), " - + "#receipt_expiration = if_not_exists(#receipt_expiration, :receipt_expiration), " - + "#receipt_level = if_not_exists(#receipt_level, :receipt_level), " - + "#account_uuid = if_not_exists(#account_uuid, :account_uuid), " - + "#redemption_time = if_not_exists(#redemption_time, :redemption_time)") - .expressionAttributeNames(Map.of( - "#ttl", KEY_TTL, - "#receipt_expiration", KEY_RECEIPT_EXPIRATION, - "#receipt_level", KEY_RECEIPT_LEVEL, - "#account_uuid", KEY_ACCOUNT_UUID, - "#redemption_time", KEY_REDEMPTION_TIME)) - .expressionAttributeValues(Map.of( - ":ttl", AttributeValues.n(rowExpiration.getEpochSecond()), - ":receipt_expiration", AttributeValues.n(receiptExpiration), - ":receipt_level", AttributeValues.n(receiptLevel), - ":account_uuid", AttributeValues.b(accountUuid), - ":redemption_time", AttributeValues.n(now.getEpochSecond()))) - .build(); - return client.updateItem(updateItemRequest).thenApply(updateItemResponse -> { - final Map attributes = updateItemResponse.attributes(); - final long ddbReceiptExpiration = Long.parseLong(attributes.get(KEY_RECEIPT_EXPIRATION).n()); - final long ddbReceiptLevel = Long.parseLong(attributes.get(KEY_RECEIPT_LEVEL).n()); - final UUID ddbAccountUuid = UUIDUtil.fromByteBuffer(attributes.get(KEY_ACCOUNT_UUID).b().asByteBuffer()); - return ddbReceiptExpiration == receiptExpiration && ddbReceiptLevel == receiptLevel && - Objects.equals(ddbAccountUuid, accountUuid); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceNotFoundException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceNotFoundException.java deleted file mode 100644 index 0ea57a0ad..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceNotFoundException.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -public class RefreshingAccountAndDeviceNotFoundException extends RuntimeException { - - public RefreshingAccountAndDeviceNotFoundException(final String message) { - super(message); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceSupplier.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceSupplier.java deleted file mode 100644 index 1c12e1177..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceSupplier.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import java.util.function.Supplier; -import org.whispersystems.textsecuregcm.util.Pair; - -public class RefreshingAccountAndDeviceSupplier implements Supplier> { - - private Account account; - private Device device; - private final AccountsManager accountsManager; - - public RefreshingAccountAndDeviceSupplier(Account account, long deviceId, AccountsManager accountsManager) { - this.account = account; - this.device = account.getDevice(deviceId) - .orElseThrow(() -> new RefreshingAccountAndDeviceNotFoundException("Could not find device")); - this.accountsManager = accountsManager; - } - - @Override - public Pair get() { - if (account.isStale()) { - account = accountsManager.getByAccountIdentifier(account.getUuid()) - .orElseThrow(() -> new RuntimeException("Could not find account")); - device = account.getDevice(device.getId()) - .orElseThrow(() -> new RefreshingAccountAndDeviceNotFoundException("Could not find device")); - } - - return new Pair<>(account, device); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswords.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswords.java deleted file mode 100644 index f25dbc744..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswords.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static java.util.Objects.requireNonNull; - -import java.time.Clock; -import java.time.Duration; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.Util; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; - -public class RegistrationRecoveryPasswords extends AbstractDynamoDbStore { - - static final String KEY_E164 = "P"; - static final String ATTR_EXP = "E"; - static final String ATTR_SALT = "S"; - static final String ATTR_HASH = "H"; - - private final String tableName; - - private final Duration expiration; - - private final DynamoDbAsyncClient asyncClient; - - private final Clock clock; - - public RegistrationRecoveryPasswords( - final String tableName, - final Duration expiration, - final DynamoDbClient dynamoDbClient, - final DynamoDbAsyncClient asyncClient) { - this(tableName, expiration, dynamoDbClient, asyncClient, Clock.systemUTC()); - } - - RegistrationRecoveryPasswords( - final String tableName, - final Duration expiration, - final DynamoDbClient dynamoDbClient, - final DynamoDbAsyncClient asyncClient, - final Clock clock) { - super(dynamoDbClient); - this.tableName = requireNonNull(tableName); - this.expiration = requireNonNull(expiration); - this.asyncClient = requireNonNull(asyncClient); - this.clock = requireNonNull(clock); - } - - public CompletableFuture> lookup(final String number) { - return asyncClient.getItem(GetItemRequest.builder() - .tableName(tableName) - .key(Map.of( - KEY_E164, AttributeValues.fromString(number))) - .build()) - .thenApply(getItemResponse -> { - final Map item = getItemResponse.item(); - if (item == null || !item.containsKey(ATTR_SALT) || !item.containsKey(ATTR_HASH)) { - return Optional.empty(); - } - final String salt = item.get(ATTR_SALT).s(); - final String hash = item.get(ATTR_HASH).s(); - return Optional.of(new SaltedTokenHash(hash, salt)); - }); - } - - public CompletableFuture addOrReplace(final String number, final SaltedTokenHash data) { - return asyncClient.putItem(PutItemRequest.builder() - .tableName(tableName) - .item(Map.of( - KEY_E164, AttributeValues.fromString(number), - ATTR_EXP, AttributeValues.fromLong(expirationSeconds()), - ATTR_SALT, AttributeValues.fromString(data.salt()), - ATTR_HASH, AttributeValues.fromString(data.hash()))) - .build()) - .thenRun(Util.NOOP); - } - - public CompletableFuture removeEntry(final String number) { - return asyncClient.deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_E164, AttributeValues.fromString(number))) - .build()) - .thenRun(Util.NOOP); - } - - private long expirationSeconds() { - return clock.instant().plus(expiration).getEpochSecond(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswordsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswordsManager.java deleted file mode 100644 index ccc657f46..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswordsManager.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static java.util.Objects.requireNonNull; - -import java.lang.invoke.MethodHandles; -import java.util.HexFormat; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; - -public class RegistrationRecoveryPasswordsManager { - - private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); - - private final RegistrationRecoveryPasswords registrationRecoveryPasswords; - - - public RegistrationRecoveryPasswordsManager(final RegistrationRecoveryPasswords registrationRecoveryPasswords) { - this.registrationRecoveryPasswords = requireNonNull(registrationRecoveryPasswords); - } - - public CompletableFuture verify(final String number, final byte[] password) { - return registrationRecoveryPasswords.lookup(number) - .thenApply(maybeHash -> maybeHash.filter(hash -> hash.verify(bytesToString(password)))) - .whenComplete((result, error) -> { - if (error != null) { - logger.warn("Failed to lookup Registration Recovery Password", error); - } - }) - .thenApply(Optional::isPresent); - } - - public CompletableFuture storeForCurrentNumber(final String number, final byte[] password) { - final String token = bytesToString(password); - final SaltedTokenHash tokenHash = SaltedTokenHash.generateFor(token); - return registrationRecoveryPasswords.addOrReplace(number, tokenHash) - .whenComplete((result, error) -> { - if (error != null) { - logger.warn("Failed to store Registration Recovery Password", error); - } - }); - } - - public CompletableFuture removeForNumber(final String number) { - // remove is a "fire-and-forget" operation, - // there is no action to be taken on its completion - return registrationRecoveryPasswords.removeEntry(number) - .whenComplete((ignored, error) -> { - if (error != null) { - logger.warn("Failed to remove Registration Recovery Password", error); - } - }); - } - - private static String bytesToString(final byte[] bytes) { - return HexFormat.of().formatHex(bytes); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java deleted file mode 100644 index c1215df12..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Pattern; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; - -public class RemoteConfig { - - @JsonProperty - @Pattern(regexp = "[A-Za-z0-9\\.]+") - private String name; - - @JsonProperty - @NotNull - @Min(0) - @Max(100) - private int percentage; - - @JsonProperty - @NotNull - private Set uuids = new HashSet<>(); - - @JsonProperty - private String defaultValue; - - @JsonProperty - private String value; - - @JsonProperty - private String hashKey; - - public RemoteConfig() {} - - public RemoteConfig(String name, int percentage, Set uuids, String defaultValue, String value, String hashKey) { - this.name = name; - this.percentage = percentage; - this.uuids = uuids; - this.defaultValue = defaultValue; - this.value = value; - this.hashKey = hashKey; - } - - public int getPercentage() { - return percentage; - } - - public String getName() { - return name; - } - - public Set getUuids() { - return uuids; - } - - public String getDefaultValue() { - return defaultValue; - } - - public String getValue() { - return value; - } - - public String getHashKey() { - return hashKey; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java deleted file mode 100644 index 8d2141be1..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.UUIDUtil; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Collectors; - -public class RemoteConfigs { - - private final DynamoDbClient dynamoDbClient; - private final String tableName; - - // Config name; string - static final String KEY_NAME = "N"; - // Rollout percentage; integer - private static final String ATTR_PERCENTAGE = "P"; - // Enrolled UUIDs (ACIs); list of byte arrays - private static final String ATTR_UUIDS = "U"; - // Default value; string - private static final String ATTR_DEFAULT_VALUE = "D"; - // Value when enrolled; string - private static final String ATTR_VALUE = "V"; - // Hash key; string - private static final String ATTR_HASH_KEY = "H"; - - public RemoteConfigs(final DynamoDbClient dynamoDbClient, final String tableName) { - this.dynamoDbClient = dynamoDbClient; - this.tableName = tableName; - } - - public void set(final RemoteConfig remoteConfig) { - final Map item = new HashMap<>(Map.of( - KEY_NAME, AttributeValues.fromString(remoteConfig.getName()), - ATTR_PERCENTAGE, AttributeValues.fromInt(remoteConfig.getPercentage()))); - - if (remoteConfig.getUuids() != null && !remoteConfig.getUuids().isEmpty()) { - final List uuidByteSets = remoteConfig.getUuids().stream() - .map(UUIDUtil::toByteBuffer) - .map(SdkBytes::fromByteBuffer) - .collect(Collectors.toList()); - - item.put(ATTR_UUIDS, AttributeValue.builder().bs(uuidByteSets).build()); - } - - if (remoteConfig.getDefaultValue() != null) { - item.put(ATTR_DEFAULT_VALUE, AttributeValues.fromString(remoteConfig.getDefaultValue())); - } - - if (remoteConfig.getValue() != null) { - item.put(ATTR_VALUE, AttributeValues.fromString(remoteConfig.getValue())); - } - - if (remoteConfig.getHashKey() != null) { - item.put(ATTR_HASH_KEY, AttributeValues.fromString(remoteConfig.getHashKey())); - } - - dynamoDbClient.putItem(PutItemRequest.builder() - .tableName(tableName) - .item(item) - .build()); - } - - public List getAll() { - return dynamoDbClient.scanPaginator(ScanRequest.builder() - .tableName(tableName) - .consistentRead(true) - .build()) - .items() - .stream() - .map(item -> { - final String name = AttributeValues.getString(item, KEY_NAME, null); - final int percentage = AttributeValues.getInt(item, ATTR_PERCENTAGE, 0); - final String defaultValue = AttributeValues.getString(item, ATTR_DEFAULT_VALUE, null); - final String value = AttributeValues.getString(item, ATTR_VALUE, null); - final String hashKey = AttributeValues.getString(item, ATTR_HASH_KEY, null); - - final Set uuids; - - if (item.containsKey(ATTR_UUIDS)) { - uuids = item.get(ATTR_UUIDS).bs().stream() - .map(sdkBytes -> UUIDUtil.fromByteBuffer(sdkBytes.asByteBuffer())) - .collect(Collectors.toSet()); - } else { - uuids = Collections.emptySet(); - } - - return new RemoteConfig(name, percentage, uuids, defaultValue, value, hashKey); - }) - .collect(Collectors.toList()); - } - - public void delete(final String name) { - dynamoDbClient.deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_NAME, AttributeValues.fromString(name))) - .build()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManager.java deleted file mode 100644 index 267cfc275..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManager.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.google.common.base.Suppliers; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - -public class RemoteConfigsManager { - - private final RemoteConfigs remoteConfigs; - - private final Supplier> remoteConfigSupplier; - - public RemoteConfigsManager(RemoteConfigs remoteConfigs) { - this.remoteConfigs = remoteConfigs; - - remoteConfigSupplier = - Suppliers.memoizeWithExpiration(remoteConfigs::getAll, 10, TimeUnit.SECONDS); - } - - public List getAll() { - return remoteConfigSupplier.get(); - } - - public void set(RemoteConfig config) { - remoteConfigs.set(config); - } - - public void delete(String name) { - remoteConfigs.delete(name); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDb.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDb.java deleted file mode 100644 index 49111f991..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDb.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.whispersystems.textsecuregcm.storage; - -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; -import software.amazon.awssdk.services.dynamodb.model.ReturnValue; -import java.time.Duration; -import java.time.Instant; -import java.util.Map; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; - -public class ReportMessageDynamoDb { - - static final String KEY_HASH = "H"; - static final String ATTR_TTL = "E"; - - private final DynamoDbClient db; - private final String tableName; - private final Duration ttl; - - private static final String REMOVED_MESSAGE_COUNTER_NAME = name(ReportMessageDynamoDb.class, "removed"); - private static final Timer REMOVED_MESSAGE_AGE_TIMER = Timer - .builder(name(ReportMessageDynamoDb.class, "removedMessageAge")) - .publishPercentiles(0.5, 0.75, 0.95, 0.99) - .distributionStatisticExpiry(Duration.ofDays(1)) - .register(Metrics.globalRegistry); - - public ReportMessageDynamoDb(final DynamoDbClient dynamoDB, final String tableName, final Duration ttl) { - this.db = dynamoDB; - this.tableName = tableName; - this.ttl = ttl; - } - - public void store(byte[] hash) { - db.putItem(PutItemRequest.builder() - .tableName(tableName) - .item(Map.of( - KEY_HASH, AttributeValues.fromByteArray(hash), - ATTR_TTL, AttributeValues.fromLong(Instant.now().plus(ttl).getEpochSecond()) - )) - .build()); - } - - public boolean remove(byte[] hash) { - final DeleteItemResponse deleteItemResponse = db.deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_HASH, AttributeValues.fromByteArray(hash))) - .returnValues(ReturnValue.ALL_OLD) - .build()); - - final boolean found = !deleteItemResponse.attributes().isEmpty(); - - if (found) { - if (deleteItemResponse.attributes().containsKey(ATTR_TTL)) { - final Instant expiration = - Instant.ofEpochSecond(Long.parseLong(deleteItemResponse.attributes().get(ATTR_TTL).n())); - - final Duration approximateAge = ttl.minus(Duration.between(Instant.now(), expiration)); - - REMOVED_MESSAGE_AGE_TIMER.record(approximateAge); - } - } - - Metrics.counter(REMOVED_MESSAGE_COUNTER_NAME, "found", String.valueOf(found)).increment(); - - return found; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java deleted file mode 100644 index 26ebe939b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import io.lettuce.core.RedisException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tags; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.util.UUIDUtil; - -public class ReportMessageManager { - - private final ReportMessageDynamoDb reportMessageDynamoDb; - private final FaultTolerantRedisCluster rateLimitCluster; - - private final Duration counterTtl; - - private final List reportedMessageListeners = new ArrayList<>(); - - private static final String REPORT_MESSAGE_COUNTER_NAME = MetricsUtil.name(ReportMessageManager.class, "reportMessage"); - private static final String FOUND_MESSAGE_TAG = "foundMessage"; - private static final String TOKEN_PRESENT_TAG = "hasReportSpamToken"; - - private static final Logger logger = LoggerFactory.getLogger(ReportMessageManager.class); - - public ReportMessageManager(final ReportMessageDynamoDb reportMessageDynamoDb, - final FaultTolerantRedisCluster rateLimitCluster, - final Duration counterTtl) { - - this.reportMessageDynamoDb = reportMessageDynamoDb; - this.rateLimitCluster = rateLimitCluster; - - this.counterTtl = counterTtl; - } - - public void addListener(final ReportedMessageListener listener) { - this.reportedMessageListeners.add(listener); - } - - public void store(String sourceAci, UUID messageGuid) { - - try { - Objects.requireNonNull(sourceAci); - - reportMessageDynamoDb.store(hash(messageGuid, sourceAci)); - } catch (final Exception e) { - logger.warn("Failed to store hash", e); - } - } - - public void report(final Optional sourceNumber, - final Optional sourceAci, - final Optional sourcePni, - final UUID messageGuid, - final UUID reporterUuid, - final Optional reportSpamToken, - final String reporterUserAgent) { - - final boolean found = sourceAci.map(uuid -> reportMessageDynamoDb.remove(hash(messageGuid, uuid.toString()))) - .orElse(false); - - Metrics.counter(REPORT_MESSAGE_COUNTER_NAME, - Tags.of(FOUND_MESSAGE_TAG, String.valueOf(found), - TOKEN_PRESENT_TAG, String.valueOf(reportSpamToken.isPresent())) - .and(UserAgentTagUtil.getPlatformTag(reporterUserAgent))) - .increment(); - - if (found) { - rateLimitCluster.useCluster(connection -> { - sourcePni.ifPresent(pni -> { - final String reportedSenderKey = getReportedSenderPniKey(pni); - connection.sync().pfadd(reportedSenderKey, reporterUuid.toString()); - connection.sync().expire(reportedSenderKey, counterTtl.toSeconds()); - }); - - sourceAci.ifPresent(aci -> { - final String reportedSenderKey = getReportedSenderAciKey(aci); - connection.sync().pfadd(reportedSenderKey, reporterUuid.toString()); - connection.sync().expire(reportedSenderKey, counterTtl.toSeconds()); - }); - }); - - sourceNumber.ifPresent(number -> - reportedMessageListeners.forEach(listener -> { - try { - listener.handleMessageReported(number, messageGuid, reporterUuid, reportSpamToken); - } catch (final Exception e) { - logger.error("Failed to notify listener of reported message", e); - } - })); - } - } - - /** - * Returns the number of times messages from the given account have been reported by recipients as spam. Note that - * this method makes a call to an external service, and callers should take care to memoize calls where possible and - * avoid unnecessary calls. - * - * @param account the account to check for recent reports - * @return the number of times the given number has been reported recently - */ - public int getRecentReportCount(final Account account) { - try { - return rateLimitCluster.withCluster( - connection -> - Math.max( - connection.sync().pfcount(getReportedSenderPniKey(account.getPhoneNumberIdentifier())).intValue(), - connection.sync().pfcount(getReportedSenderAciKey(account.getUuid())).intValue())); - } catch (final RedisException e) { - return 0; - } - } - - private byte[] hash(UUID messageGuid, String otherId) { - final MessageDigest sha256; - try { - sha256 = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - - sha256.update(UUIDUtil.toBytes(messageGuid)); - sha256.update(otherId.getBytes(StandardCharsets.UTF_8)); - - return sha256.digest(); - } - - private static String getReportedSenderAciKey(final UUID aci) { - return "reported_account::" + aci.toString(); - } - - private static String getReportedSenderPniKey(final UUID pni) { - return "reported_pni::" + pni.toString(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportedMessageListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportedMessageListener.java deleted file mode 100644 index 96e580b11..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportedMessageListener.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import java.util.Optional; -import java.util.UUID; - -public interface ReportedMessageListener { - - void handleMessageReported(String sourceNumber, UUID messageGuid, UUID reporterUuid, Optional reportSpamToken); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/StoredVerificationCodeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/StoredVerificationCodeManager.java deleted file mode 100644 index c4c73128c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/StoredVerificationCodeManager.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import java.util.Optional; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; - -public class StoredVerificationCodeManager { - - private final VerificationCodeStore verificationCodeStore; - - public StoredVerificationCodeManager(final VerificationCodeStore verificationCodeStore) { - this.verificationCodeStore = verificationCodeStore; - } - - public void store(String number, StoredVerificationCode code) { - verificationCodeStore.insert(number, code); - } - - public void remove(String number) { - verificationCodeStore.remove(number); - } - - public Optional getCodeForNumber(String number) { - return verificationCodeStore.findForNumber(number); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java deleted file mode 100644 index 8837e1eb8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java +++ /dev/null @@ -1,429 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.whispersystems.textsecuregcm.util.AttributeValues.b; -import static org.whispersystems.textsecuregcm.util.AttributeValues.n; -import static org.whispersystems.textsecuregcm.util.AttributeValues.s; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Throwables; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.time.Instant; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import javax.ws.rs.ClientErrorException; -import javax.ws.rs.core.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; -import org.whispersystems.textsecuregcm.util.Pair; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.QueryRequest; -import software.amazon.awssdk.services.dynamodb.model.ReturnValue; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -public class SubscriptionManager { - - private static final Logger logger = LoggerFactory.getLogger(SubscriptionManager.class); - - private static final int USER_LENGTH = 16; - - public static final String KEY_USER = "U"; // B (Hash Key) - public static final String KEY_PASSWORD = "P"; // B - public static final String KEY_PROCESSOR_ID_CUSTOMER_ID = "PC"; // B (GSI Hash Key of `pc_to_u` index) - public static final String KEY_CREATED_AT = "R"; // N - public static final String KEY_SUBSCRIPTION_ID = "S"; // S - public static final String KEY_SUBSCRIPTION_CREATED_AT = "T"; // N - public static final String KEY_SUBSCRIPTION_LEVEL = "L"; - public static final String KEY_SUBSCRIPTION_LEVEL_CHANGED_AT = "V"; // N - public static final String KEY_ACCESSED_AT = "A"; // N - public static final String KEY_CANCELED_AT = "B"; // N - public static final String KEY_CURRENT_PERIOD_ENDS_AT = "D"; // N - - public static final String INDEX_NAME = "pc_to_u"; // Hash Key "PC" - - public static class Record { - - public final byte[] user; - public final byte[] password; - public final Instant createdAt; - @VisibleForTesting - @Nullable - ProcessorCustomer processorCustomer; - @Nullable - public String subscriptionId; - public Instant subscriptionCreatedAt; - public Long subscriptionLevel; - public Instant subscriptionLevelChangedAt; - public Instant accessedAt; - public Instant canceledAt; - public Instant currentPeriodEndsAt; - - private Record(byte[] user, byte[] password, Instant createdAt) { - this.user = checkUserLength(user); - this.password = Objects.requireNonNull(password); - this.createdAt = Objects.requireNonNull(createdAt); - } - - public static Record from(byte[] user, Map item) { - Record record = new Record( - user, - item.get(KEY_PASSWORD).b().asByteArray(), - getInstant(item, KEY_CREATED_AT)); - - final Pair processorCustomerId = getProcessorAndCustomer(item); - if (processorCustomerId != null) { - record.processorCustomer = new ProcessorCustomer(processorCustomerId.second(), processorCustomerId.first()); - } - record.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID); - record.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT); - record.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL); - record.subscriptionLevelChangedAt = getInstant(item, KEY_SUBSCRIPTION_LEVEL_CHANGED_AT); - record.accessedAt = getInstant(item, KEY_ACCESSED_AT); - record.canceledAt = getInstant(item, KEY_CANCELED_AT); - record.currentPeriodEndsAt = getInstant(item, KEY_CURRENT_PERIOD_ENDS_AT); - return record; - } - - public Optional getProcessorCustomer() { - return Optional.ofNullable(processorCustomer); - } - - /** - * Extracts the active processor and customer from a single attribute value in the given item. - *

- * Until existing data is migrated, this may return {@code null}. - */ - @Nullable - private static Pair getProcessorAndCustomer(Map item) { - - final AttributeValue attributeValue = item.get(KEY_PROCESSOR_ID_CUSTOMER_ID); - - if (attributeValue == null) { - // temporarily allow null values - return null; - } - - final byte[] processorAndCustomerId = attributeValue.b().asByteArray(); - final byte processorId = processorAndCustomerId[0]; - - final SubscriptionProcessor processor = SubscriptionProcessor.forId(processorId); - if (processor == null) { - throw new IllegalStateException("unknown processor id: " + processorId); - } - - final String customerId = new String(processorAndCustomerId, 1, processorAndCustomerId.length - 1, - StandardCharsets.UTF_8); - - return new Pair<>(processor, customerId); - } - - private static String getString(Map item, String key) { - AttributeValue attributeValue = item.get(key); - if (attributeValue == null) { - return null; - } - return attributeValue.s(); - } - - private static Long getLong(Map item, String key) { - AttributeValue attributeValue = item.get(key); - if (attributeValue == null || attributeValue.n() == null) { - return null; - } - return Long.valueOf(attributeValue.n()); - } - - private static Instant getInstant(Map item, String key) { - AttributeValue attributeValue = item.get(key); - if (attributeValue == null || attributeValue.n() == null) { - return null; - } - return Instant.ofEpochSecond(Long.parseLong(attributeValue.n())); - } - } - - private final String table; - private final DynamoDbAsyncClient client; - - public SubscriptionManager( - @Nonnull String table, - @Nonnull DynamoDbAsyncClient client) { - this.table = Objects.requireNonNull(table); - this.client = Objects.requireNonNull(client); - } - - /** - * Looks in the GSI for a record with the given customer id and returns the user id. - */ - public CompletableFuture getSubscriberUserByProcessorCustomer(ProcessorCustomer processorCustomer) { - QueryRequest query = QueryRequest.builder() - .tableName(table) - .indexName(INDEX_NAME) - .keyConditionExpression("#processor_customer_id = :processor_customer_id") - .projectionExpression("#user") - .expressionAttributeNames(Map.of( - "#processor_customer_id", KEY_PROCESSOR_ID_CUSTOMER_ID, - "#user", KEY_USER)) - .expressionAttributeValues(Map.of( - ":processor_customer_id", b(processorCustomer.toDynamoBytes()))) - .build(); - return client.query(query).thenApply(queryResponse -> { - int count = queryResponse.count(); - if (count == 0) { - return null; - } else if (count > 1) { - logger.error("expected invariant of 1-1 subscriber-customer violated for customer {} ({})", - processorCustomer.customerId(), processorCustomer.processor()); - throw new IllegalStateException( - "expected invariant of 1-1 subscriber-customer violated for customer " + processorCustomer); - } else { - Map result = queryResponse.items().get(0); - return result.get(KEY_USER).b().asByteArray(); - } - }); - } - - public static class GetResult { - - public static final GetResult NOT_STORED = new GetResult(Type.NOT_STORED, null); - public static final GetResult PASSWORD_MISMATCH = new GetResult(Type.PASSWORD_MISMATCH, null); - - public enum Type { - NOT_STORED, - PASSWORD_MISMATCH, - FOUND - } - - public final Type type; - public final Record record; - - private GetResult(Type type, Record record) { - this.type = type; - this.record = record; - } - - public static GetResult found(Record record) { - return new GetResult(Type.FOUND, record); - } - } - - /** - * Looks up a record with the given {@code user} and validates the {@code hmac} before returning it. - */ - public CompletableFuture get(byte[] user, byte[] hmac) { - return getUser(user).thenApply(getItemResponse -> { - if (!getItemResponse.hasItem()) { - return GetResult.NOT_STORED; - } - - Record record = Record.from(user, getItemResponse.item()); - if (!MessageDigest.isEqual(hmac, record.password)) { - return GetResult.PASSWORD_MISMATCH; - } - return GetResult.found(record); - }); - } - - private CompletableFuture getUser(byte[] user) { - checkUserLength(user); - - GetItemRequest request = GetItemRequest.builder() - .consistentRead(Boolean.TRUE) - .tableName(table) - .key(Map.of(KEY_USER, b(user))) - .build(); - - return client.getItem(request); - } - - public CompletableFuture create(byte[] user, byte[] password, Instant createdAt) { - checkUserLength(user); - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_USER, b(user))) - .returnValues(ReturnValue.ALL_NEW) - .conditionExpression("attribute_not_exists(#user) OR #password = :password") - .updateExpression("SET " - + "#password = if_not_exists(#password, :password), " - + "#created_at = if_not_exists(#created_at, :created_at), " - + "#accessed_at = if_not_exists(#accessed_at, :accessed_at)" - ) - .expressionAttributeNames(Map.of( - "#user", KEY_USER, - "#password", KEY_PASSWORD, - "#created_at", KEY_CREATED_AT, - "#accessed_at", KEY_ACCESSED_AT) - ) - .expressionAttributeValues(Map.of( - ":password", b(password), - ":created_at", n(createdAt.getEpochSecond()), - ":accessed_at", n(createdAt.getEpochSecond())) - ) - .build(); - return client.updateItem(request).handle((updateItemResponse, throwable) -> { - if (throwable != null) { - if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) { - return null; - } - Throwables.throwIfUnchecked(throwable); - throw new CompletionException(throwable); - } - - return Record.from(user, updateItemResponse.attributes()); - }); - } - - /** - * Sets the processor and customer ID for the given user record. - * - * @return the user record. - */ - public CompletableFuture setProcessorAndCustomerId(Record userRecord, - ProcessorCustomer activeProcessorCustomer, Instant updatedAt) { - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_USER, b(userRecord.user))) - .returnValues(ReturnValue.ALL_NEW) - .conditionExpression("attribute_not_exists(#processor_customer_id)") - .updateExpression("SET " - + "#processor_customer_id = :processor_customer_id, " - + "#accessed_at = :accessed_at" - ) - .expressionAttributeNames(Map.of( - "#accessed_at", KEY_ACCESSED_AT, - "#processor_customer_id", KEY_PROCESSOR_ID_CUSTOMER_ID - )) - .expressionAttributeValues(Map.of( - ":accessed_at", n(updatedAt.getEpochSecond()), - ":processor_customer_id", b(activeProcessorCustomer.toDynamoBytes()) - )).build(); - - return client.updateItem(request) - .thenApply(updateItemResponse -> Record.from(userRecord.user, updateItemResponse.attributes())) - .exceptionallyCompose(throwable -> { - if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) { - throw new ClientErrorException(Response.Status.CONFLICT); - } - Throwables.throwIfUnchecked(throwable); - throw new CompletionException(throwable); - }); - } - - public CompletableFuture accessedAt(byte[] user, Instant accessedAt) { - checkUserLength(user); - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_USER, b(user))) - .returnValues(ReturnValue.NONE) - .updateExpression("SET #accessed_at = :accessed_at") - .expressionAttributeNames(Map.of("#accessed_at", KEY_ACCESSED_AT)) - .expressionAttributeValues(Map.of(":accessed_at", n(accessedAt.getEpochSecond()))) - .build(); - return client.updateItem(request).thenApply(updateItemResponse -> null); - } - - public CompletableFuture canceledAt(byte[] user, Instant canceledAt) { - checkUserLength(user); - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_USER, b(user))) - .returnValues(ReturnValue.NONE) - .updateExpression("SET " - + "#accessed_at = :accessed_at, " - + "#canceled_at = :canceled_at " - + "REMOVE #subscription_id") - .expressionAttributeNames(Map.of( - "#accessed_at", KEY_ACCESSED_AT, - "#canceled_at", KEY_CANCELED_AT, - "#subscription_id", KEY_SUBSCRIPTION_ID)) - .expressionAttributeValues(Map.of( - ":accessed_at", n(canceledAt.getEpochSecond()), - ":canceled_at", n(canceledAt.getEpochSecond()))) - .build(); - return client.updateItem(request).thenApply(updateItemResponse -> null); - } - - public CompletableFuture subscriptionCreated( - byte[] user, String subscriptionId, Instant subscriptionCreatedAt, long level) { - checkUserLength(user); - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_USER, b(user))) - .returnValues(ReturnValue.NONE) - .updateExpression("SET " - + "#accessed_at = :accessed_at, " - + "#subscription_id = :subscription_id, " - + "#subscription_created_at = :subscription_created_at, " - + "#subscription_level = :subscription_level, " - + "#subscription_level_changed_at = :subscription_level_changed_at") - .expressionAttributeNames(Map.of( - "#accessed_at", KEY_ACCESSED_AT, - "#subscription_id", KEY_SUBSCRIPTION_ID, - "#subscription_created_at", KEY_SUBSCRIPTION_CREATED_AT, - "#subscription_level", KEY_SUBSCRIPTION_LEVEL, - "#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT)) - .expressionAttributeValues(Map.of( - ":accessed_at", n(subscriptionCreatedAt.getEpochSecond()), - ":subscription_id", s(subscriptionId), - ":subscription_created_at", n(subscriptionCreatedAt.getEpochSecond()), - ":subscription_level", n(level), - ":subscription_level_changed_at", n(subscriptionCreatedAt.getEpochSecond()))) - .build(); - return client.updateItem(request).thenApply(updateItemResponse -> null); - } - - public CompletableFuture subscriptionLevelChanged( - byte[] user, Instant subscriptionLevelChangedAt, long level, String subscriptionId) { - checkUserLength(user); - - UpdateItemRequest request = UpdateItemRequest.builder() - .tableName(table) - .key(Map.of(KEY_USER, b(user))) - .returnValues(ReturnValue.NONE) - .updateExpression("SET " - + "#accessed_at = :accessed_at, " - + "#subscription_id = :subscription_id, " - + "#subscription_level = :subscription_level, " - + "#subscription_level_changed_at = :subscription_level_changed_at") - .expressionAttributeNames(Map.of( - "#accessed_at", KEY_ACCESSED_AT, - "#subscription_id", KEY_SUBSCRIPTION_ID, - "#subscription_level", KEY_SUBSCRIPTION_LEVEL, - "#subscription_level_changed_at", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT)) - .expressionAttributeValues(Map.of( - ":accessed_at", n(subscriptionLevelChangedAt.getEpochSecond()), - ":subscription_id", s(subscriptionId), - ":subscription_level", n(level), - ":subscription_level_changed_at", n(subscriptionLevelChangedAt.getEpochSecond()))) - .build(); - return client.updateItem(request).thenApply(updateItemResponse -> null); - } - - private static byte[] checkUserLength(final byte[] user) { - if (user.length != USER_LENGTH) { - throw new IllegalArgumentException("user length is wrong; expected " + USER_LENGTH + "; was " + user.length); - } - return user; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameHashNotAvailableException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameHashNotAvailableException.java deleted file mode 100644 index 04f81f625..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameHashNotAvailableException.java +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -public class UsernameHashNotAvailableException extends Exception { -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameReservationNotFoundException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameReservationNotFoundException.java deleted file mode 100644 index 066e89994..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameReservationNotFoundException.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -public class UsernameReservationNotFoundException extends Exception { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStore.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStore.java deleted file mode 100644 index 9f80d5f83..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStore.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Timer; -import java.time.Instant; -import java.util.Map; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; - -public class VerificationCodeStore { - - private final DynamoDbClient dynamoDbClient; - private final String tableName; - - private final Timer insertTimer; - private final Timer getTimer; - private final Timer removeTimer; - - @VisibleForTesting - static final String KEY_E164 = "P"; - - private static final String ATTR_STORED_CODE = "C"; - private static final String ATTR_TTL = "E"; - - private static final Logger log = LoggerFactory.getLogger(VerificationCodeStore.class); - - public VerificationCodeStore(final DynamoDbClient dynamoDbClient, final String tableName) { - this.dynamoDbClient = dynamoDbClient; - this.tableName = tableName; - - this.insertTimer = Metrics.timer(name(getClass(), "insert"), "table", tableName); - this.getTimer = Metrics.timer(name(getClass(), "get"), "table", tableName); - this.removeTimer = Metrics.timer(name(getClass(), "remove"), "table", tableName); - } - - public void insert(final String number, final StoredVerificationCode verificationCode) { - insertTimer.record(() -> { - try { - dynamoDbClient.putItem(PutItemRequest.builder() - .tableName(tableName) - .item(Map.of( - KEY_E164, AttributeValues.fromString(number), - ATTR_STORED_CODE, AttributeValues.fromString(SystemMapper.getMapper().writeValueAsString(verificationCode)), - ATTR_TTL, AttributeValues.fromLong(getExpirationTimestamp(verificationCode)))) - .build()); - } catch (final JsonProcessingException e) { - // This should never happen when writing directly to a string except in cases of serious misconfiguration, which - // would be caught by tests. - throw new AssertionError(e); - } - }); - } - - private long getExpirationTimestamp(final StoredVerificationCode storedVerificationCode) { - return Instant.ofEpochMilli(storedVerificationCode.timestamp()).plus(StoredVerificationCode.EXPIRATION).getEpochSecond(); - } - - public Optional findForNumber(final String number) { - return getTimer.record(() -> { - final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder() - .tableName(tableName) - .consistentRead(true) - .key(Map.of(KEY_E164, AttributeValues.fromString(number))) - .build()); - - try { - return response.hasItem() - ? filterMaybeExpiredCode( - SystemMapper.getMapper().readValue(response.item().get(ATTR_STORED_CODE).s(), StoredVerificationCode.class)) - : Optional.empty(); - } catch (final JsonProcessingException e) { - log.error("Failed to parse stored verification code", e); - return Optional.empty(); - } - }); - } - - private Optional filterMaybeExpiredCode(StoredVerificationCode storedVerificationCode) { - // It's possible for DynamoDB to return items after their expiration time (although it is very unlikely for small - // tables) - if (getExpirationTimestamp(storedVerificationCode) < Instant.now().getEpochSecond()) { - return Optional.empty(); - } - - return Optional.of(storedVerificationCode); - } - - public void remove(final String number) { - removeTimer.record(() -> { - dynamoDbClient.deleteItem(DeleteItemRequest.builder() - .tableName(tableName) - .key(Map.of(KEY_E164, AttributeValues.fromString(number))) - .build()); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java deleted file mode 100644 index 38cd0cf52..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import org.whispersystems.textsecuregcm.util.ByteArrayAdapter; -import java.util.Arrays; -import java.util.Objects; - -public class VersionedProfile { - - private final String version; - private final String name; - private final String avatar; - private final String aboutEmoji; - private final String about; - private final String paymentAddress; - - @JsonSerialize(using = ByteArrayAdapter.Serializing.class) - @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) - private byte[] commitment; - - @JsonCreator - public VersionedProfile( - @JsonProperty("version") final String version, - @JsonProperty("name") final String name, - @JsonProperty("avatar") final String avatar, - @JsonProperty("aboutEmoji") final String aboutEmoji, - @JsonProperty("about") final String about, - @JsonProperty("paymentAddress") final String paymentAddress, - @JsonProperty("commitment") final byte[] commitment) { - this.version = version; - this.name = name; - this.avatar = avatar; - this.aboutEmoji = aboutEmoji; - this.about = about; - this.paymentAddress = paymentAddress; - this.commitment = commitment; - } - - public String getVersion() { - return version; - } - - public String getName() { - return name; - } - - public String getAvatar() { - return avatar; - } - - public String getAboutEmoji() { - return aboutEmoji; - } - - public String getAbout() { - return about; - } - - public String getPaymentAddress() { - return paymentAddress; - } - - public byte[] getCommitment() { - return commitment; - } - - @Override - public boolean equals(final Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - final VersionedProfile that = (VersionedProfile) o; - return Objects.equals(version, that.version) && Objects.equals(name, that.name) && Objects.equals(avatar, - that.avatar) && Objects.equals(aboutEmoji, that.aboutEmoji) && Objects.equals(about, that.about) - && Objects.equals(paymentAddress, that.paymentAddress) && Arrays.equals(commitment, that.commitment); - } - - @Override - public int hashCode() { - int result = Objects.hash(version, name, avatar, aboutEmoji, about, paymentAddress); - result = 31 * result + Arrays.hashCode(commitment); - return result; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java deleted file mode 100644 index 2c59fea0c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java +++ /dev/null @@ -1,331 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import com.apollographql.apollo3.api.ApolloResponse; -import com.apollographql.apollo3.api.Operation; -import com.apollographql.apollo3.api.Operations; -import com.apollographql.apollo3.api.Optional; -import com.apollographql.apollo3.api.json.BufferedSinkJsonWriter; -import com.braintree.graphql.client.type.ChargePaymentMethodInput; -import com.braintree.graphql.client.type.CreatePayPalBillingAgreementInput; -import com.braintree.graphql.client.type.CreatePayPalOneTimePaymentInput; -import com.braintree.graphql.client.type.CustomFieldInput; -import com.braintree.graphql.client.type.MonetaryAmountInput; -import com.braintree.graphql.client.type.PayPalBillingAgreementChargePattern; -import com.braintree.graphql.client.type.PayPalBillingAgreementExperienceProfileInput; -import com.braintree.graphql.client.type.PayPalBillingAgreementInput; -import com.braintree.graphql.client.type.PayPalExperienceProfileInput; -import com.braintree.graphql.client.type.PayPalIntent; -import com.braintree.graphql.client.type.PayPalLandingPageType; -import com.braintree.graphql.client.type.PayPalOneTimePaymentInput; -import com.braintree.graphql.client.type.PayPalProductAttributesInput; -import com.braintree.graphql.client.type.PayPalUserAction; -import com.braintree.graphql.client.type.TokenizePayPalBillingAgreementInput; -import com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput; -import com.braintree.graphql.client.type.TransactionInput; -import com.braintree.graphql.client.type.VaultPaymentMethodInput; -import com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation; -import com.braintree.graphql.clientoperation.CreatePayPalBillingAgreementMutation; -import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation; -import com.braintree.graphql.clientoperation.TokenizePayPalBillingAgreementMutation; -import com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation; -import com.braintree.graphql.clientoperation.VaultPaymentMethodMutation; -import java.math.BigDecimal; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Base64; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import javax.ws.rs.ServiceUnavailableException; -import okio.Buffer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; - -class BraintreeGraphqlClient { - - // required header value, recommended to be the date the integration began - // https://graphql.braintreepayments.com/guides/making_api_calls/#the-braintree-version-header - private static final String BRAINTREE_VERSION = "2022-10-01"; - - private static final Logger logger = LoggerFactory.getLogger(BraintreeGraphqlClient.class); - - private final FaultTolerantHttpClient httpClient; - private final URI graphqlUri; - private final String authorizationHeader; - - BraintreeGraphqlClient(final FaultTolerantHttpClient httpClient, - final String graphqlUri, - final String publicKey, - final String privateKey) { - this.httpClient = httpClient; - try { - this.graphqlUri = new URI(graphqlUri); - } catch (URISyntaxException e) { - throw new IllegalArgumentException("Invalid URI", e); - } - // “public”/“private” key is a bit of a misnomer, but we follow the upstream nomenclature - // they are used for Basic auth similar to “client key”/“client secret” credentials - this.authorizationHeader = "Basic " + Base64.getEncoder().encodeToString((publicKey + ":" + privateKey).getBytes()); - } - - CompletableFuture createPayPalOneTimePayment( - final BigDecimal amount, final String currency, final String returnUrl, - final String cancelUrl, final String locale) { - - final CreatePayPalOneTimePaymentInput input = buildCreatePayPalOneTimePaymentInput(amount, currency, returnUrl, - cancelUrl, locale); - final CreatePayPalOneTimePaymentMutation mutation = new CreatePayPalOneTimePaymentMutation(input); - final HttpRequest request = buildRequest(mutation); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply(httpResponse -> - { - // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” - // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ - final CreatePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); - return data.createPayPalOneTimePayment; - }); - } - - private static CreatePayPalOneTimePaymentInput buildCreatePayPalOneTimePaymentInput(BigDecimal amount, - String currency, String returnUrl, String cancelUrl, String locale) { - - return new CreatePayPalOneTimePaymentInput( - Optional.absent(), - Optional.absent(), // merchant account ID will be specified when charging - new MonetaryAmountInput(amount.toString(), currency), // this could potentially use a CustomScalarAdapter - cancelUrl, - Optional.absent(), - PayPalIntent.SALE, - Optional.absent(), - Optional.present(false), // offerPayLater, - Optional.absent(), - Optional.present( - new PayPalExperienceProfileInput(Optional.present("Signal"), - Optional.present(false), - Optional.present(PayPalLandingPageType.LOGIN), - Optional.present(locale), - Optional.absent(), - Optional.present(PayPalUserAction.COMMIT))), - Optional.absent(), - Optional.absent(), - returnUrl, - Optional.absent(), - Optional.absent() - ); - } - - CompletableFuture tokenizePayPalOneTimePayment( - final String payerId, final String paymentId, final String paymentToken) { - - final TokenizePayPalOneTimePaymentInput input = new TokenizePayPalOneTimePaymentInput( - Optional.absent(), - Optional.absent(), // merchant account ID will be specified when charging - new PayPalOneTimePaymentInput(payerId, paymentId, paymentToken) - ); - - final TokenizePayPalOneTimePaymentMutation mutation = new TokenizePayPalOneTimePaymentMutation(input); - final HttpRequest request = buildRequest(mutation); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply(httpResponse -> { - // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” - // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ - final TokenizePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); - return data.tokenizePayPalOneTimePayment; - }); - } - - CompletableFuture chargeOneTimePayment( - final String paymentMethodId, final BigDecimal amount, final String merchantAccount, final long level) { - - final List customFields = List.of( - new CustomFieldInput("level", Optional.present(Long.toString(level)))); - - final ChargePaymentMethodInput input = buildChargePaymentMethodInput(paymentMethodId, amount, merchantAccount, - customFields); - final ChargePayPalOneTimePaymentMutation mutation = new ChargePayPalOneTimePaymentMutation(input); - final HttpRequest request = buildRequest(mutation); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply(httpResponse -> { - // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” - // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ - final ChargePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, - mutation); - return data.chargePaymentMethod; - }); - } - - private static ChargePaymentMethodInput buildChargePaymentMethodInput(String paymentMethodId, BigDecimal amount, - String merchantAccount, List customFields) { - - return new ChargePaymentMethodInput( - Optional.absent(), - paymentMethodId, - new TransactionInput( - // documented as “amount: whole number, or exactly two or three decimal places” - amount.toString(), // this could potentially use a CustomScalarAdapter - Optional.present(merchantAccount), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.present(customFields), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent(), - Optional.absent() - ) - ); - } - - public CompletableFuture createPayPalBillingAgreement( - final String returnUrl, final String cancelUrl, final String locale) { - - final CreatePayPalBillingAgreementInput input = buildCreatePayPalBillingAgreementInput(returnUrl, cancelUrl, - locale); - final CreatePayPalBillingAgreementMutation mutation = new CreatePayPalBillingAgreementMutation(input); - final HttpRequest request = buildRequest(mutation); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply(httpResponse -> { - // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” - // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ - final CreatePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); - return data.createPayPalBillingAgreement; - }); - } - - private static CreatePayPalBillingAgreementInput buildCreatePayPalBillingAgreementInput(String returnUrl, - String cancelUrl, String locale) { - - return new CreatePayPalBillingAgreementInput( - Optional.absent(), - Optional.absent(), - returnUrl, - cancelUrl, - Optional.absent(), - Optional.absent(), - Optional.present(false), // offerPayPalCredit - Optional.absent(), - Optional.present( - new PayPalBillingAgreementExperienceProfileInput(Optional.present("Signal"), - Optional.present(false), // collectShippingAddress - Optional.present(PayPalLandingPageType.LOGIN), - Optional.present(locale), - Optional.absent())), - Optional.absent(), - Optional.present(new PayPalProductAttributesInput( - Optional.present(PayPalBillingAgreementChargePattern.RECURRING_PREPAID) - )) - ); - } - - public CompletableFuture tokenizePayPalBillingAgreement( - final String billingAgreementToken) { - - final TokenizePayPalBillingAgreementInput input = new TokenizePayPalBillingAgreementInput( - Optional.absent(), - new PayPalBillingAgreementInput(billingAgreementToken)); - final TokenizePayPalBillingAgreementMutation mutation = new TokenizePayPalBillingAgreementMutation(input); - final HttpRequest request = buildRequest(mutation); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply(httpResponse -> { - // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” - // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ - final TokenizePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); - return data.tokenizePayPalBillingAgreement; - }); - } - - public CompletableFuture vaultPaymentMethod(final String customerId, - final String paymentMethodId) { - - final VaultPaymentMethodInput input = buildVaultPaymentMethodInput(customerId, paymentMethodId); - final VaultPaymentMethodMutation mutation = new VaultPaymentMethodMutation(input); - final HttpRequest request = buildRequest(mutation); - - return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply(httpResponse -> { - // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data” - // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/ - final VaultPaymentMethodMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation); - return data.vaultPaymentMethod; - }); - } - - private static VaultPaymentMethodInput buildVaultPaymentMethodInput(String customerId, String paymentMethodId) { - return new VaultPaymentMethodInput( - Optional.absent(), - paymentMethodId, - Optional.absent(), - Optional.absent(), - Optional.present(customerId), - Optional.absent(), - Optional.absent() - ); - } - - /** - * Verifies that the HTTP response has a {@code 200} status code and the GraphQL response has no errors, otherwise - * throws a {@link ServiceUnavailableException}. - */ - private , U extends Operation.Data> U assertSuccessAndExtractData( - HttpResponse httpResponse, T operation) { - - if (httpResponse.statusCode() != 200) { - logger.warn("Received HTTP response status {} ({})", httpResponse.statusCode(), - httpResponse.headers().firstValue("paypal-debug-id").orElse("")); - throw new ServiceUnavailableException(); - } - - ApolloResponse response = Operations.parseJsonResponse(operation, httpResponse.body()); - - if (response.hasErrors() || response.data == null) { - //noinspection ConstantConditions - response.errors.forEach( - error -> { - final Object legacyCode = java.util.Optional.ofNullable(error.getExtensions()) - .map(extensions -> extensions.get("legacyCode")) - .orElse(""); - logger.warn("Received GraphQL error for {}: \"{}\" (legacyCode: {})", - response.operation.name(), error.getMessage(), legacyCode); - }); - - throw new ServiceUnavailableException(); - } - - return response.data; - } - - private HttpRequest buildRequest(final Operation operation) { - - final Buffer buffer = new Buffer(); - Operations.composeJsonRequest(operation, new BufferedSinkJsonWriter(buffer)); - - return HttpRequest.newBuilder() - .uri(graphqlUri) - .method("POST", HttpRequest.BodyPublishers.ofString(buffer.readUtf8())) - .header("Content-Type", "application/json") - .header("Authorization", authorizationHeader) - .header("Braintree-Version", BRAINTREE_VERSION) - .build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java deleted file mode 100644 index 1ba3738a2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java +++ /dev/null @@ -1,520 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import com.braintreegateway.BraintreeGateway; -import com.braintreegateway.ClientTokenRequest; -import com.braintreegateway.Customer; -import com.braintreegateway.CustomerRequest; -import com.braintreegateway.Plan; -import com.braintreegateway.ResourceCollection; -import com.braintreegateway.Result; -import com.braintreegateway.Subscription; -import com.braintreegateway.SubscriptionRequest; -import com.braintreegateway.Transaction; -import com.braintreegateway.TransactionSearchRequest; -import com.braintreegateway.exceptions.BraintreeException; -import com.braintreegateway.exceptions.NotFoundException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.math.BigDecimal; -import java.time.Instant; -import java.util.Comparator; -import java.util.HexFormat; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.Executor; -import javax.annotation.Nullable; -import javax.ws.rs.ClientErrorException; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; - -public class BraintreeManager implements SubscriptionProcessorManager { - - private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class); - - private static final String PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE = "2094"; - private final BraintreeGateway braintreeGateway; - private final BraintreeGraphqlClient braintreeGraphqlClient; - private final Executor executor; - private final Set supportedCurrencies; - private final Map currenciesToMerchantAccounts; - - public BraintreeManager(final String braintreeMerchantId, final String braintreePublicKey, - final String braintreePrivateKey, - final String braintreeEnvironment, - final Set supportedCurrencies, - final Map currenciesToMerchantAccounts, - final String graphqlUri, - final CircuitBreakerConfiguration circuitBreakerConfiguration, - final Executor executor) { - - this.braintreeGateway = new BraintreeGateway(braintreeEnvironment, braintreeMerchantId, braintreePublicKey, - braintreePrivateKey); - this.supportedCurrencies = supportedCurrencies; - this.currenciesToMerchantAccounts = currenciesToMerchantAccounts; - - final FaultTolerantHttpClient httpClient = FaultTolerantHttpClient.newBuilder() - .withName("braintree-graphql") - .withCircuitBreaker(circuitBreakerConfiguration) - .withExecutor(executor) - .build(); - this.braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, graphqlUri, braintreePublicKey, - braintreePrivateKey); - this.executor = executor; - } - - @Override - public Set getSupportedCurrencies() { - return supportedCurrencies; - } - - @Override - public SubscriptionProcessor getProcessor() { - return SubscriptionProcessor.BRAINTREE; - } - - @Override - public boolean supportsPaymentMethod(final PaymentMethod paymentMethod) { - return paymentMethod == PaymentMethod.PAYPAL; - } - - @Override - public boolean supportsCurrency(final String currency) { - return supportedCurrencies.contains(currency.toLowerCase(Locale.ROOT)); - } - - - @Override - public CompletableFuture getPaymentDetails(final String paymentId) { - return CompletableFuture.supplyAsync(() -> { - try { - final Transaction transaction = braintreeGateway.transaction().find(paymentId); - - return new PaymentDetails(transaction.getGraphQLId(), - transaction.getCustomFields(), - getPaymentStatus(transaction.getStatus()), - transaction.getCreatedAt().toInstant()); - - } catch (final NotFoundException e) { - return null; - } - }, executor); - } - - public CompletableFuture createOneTimePayment(String currency, long amount, - String locale, String returnUrl, String cancelUrl) { - return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount), - currency.toUpperCase(Locale.ROOT), returnUrl, - cancelUrl, locale) - .thenApply(result -> new PayPalOneTimePaymentApprovalDetails((String) result.approvalUrl, result.paymentId)); - } - - public CompletableFuture captureOneTimePayment(String payerId, String paymentId, - String paymentToken, String currency, long amount, long level) { - return braintreeGraphqlClient.tokenizePayPalOneTimePayment(payerId, paymentId, paymentToken) - .thenCompose(response -> braintreeGraphqlClient.chargeOneTimePayment( - response.paymentMethod.id, - convertApiAmountToBraintreeAmount(currency, amount), - currenciesToMerchantAccounts.get(currency.toLowerCase(Locale.ROOT)), - level) - .thenComposeAsync(chargeResponse -> { - - final PaymentStatus paymentStatus = getPaymentStatus(chargeResponse.transaction.status); - if (paymentStatus == PaymentStatus.SUCCEEDED || paymentStatus == PaymentStatus.PROCESSING) { - return CompletableFuture.completedFuture(new PayPalChargeSuccessDetails(chargeResponse.transaction.id)); - } - - // the GraphQL/Apollo interfaces are a tad unwieldy for this type of status checking - final Transaction unsuccessfulTx = braintreeGateway.transaction().find(chargeResponse.transaction.id); - - if (PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE.equals(unsuccessfulTx.getProcessorResponseCode()) - || Transaction.GatewayRejectionReason.DUPLICATE.equals( - unsuccessfulTx.getGatewayRejectionReason())) { - // the payment has already been charged - maybe a previous call timed out or was interrupted - - // in any case, check for a successful transaction with the paymentId - final ResourceCollection search = braintreeGateway.transaction() - .search(new TransactionSearchRequest() - .paypalPaymentId().is(paymentId) - .status().in( - Transaction.Status.SETTLED, - Transaction.Status.SETTLING, - Transaction.Status.SUBMITTED_FOR_SETTLEMENT, - Transaction.Status.SETTLEMENT_PENDING - ) - ); - - if (search.getMaximumSize() == 0) { - return CompletableFuture.failedFuture( - new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR)); - } - - final Transaction successfulTx = search.getFirst(); - - return CompletableFuture.completedFuture( - new PayPalChargeSuccessDetails(successfulTx.getGraphQLId())); - } - - logger.info("PayPal charge unexpectedly failed: {}", unsuccessfulTx.getProcessorResponseCode()); - - return CompletableFuture.failedFuture( - new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR)); - - }, executor)); - } - - private static PaymentStatus getPaymentStatus(Transaction.Status status) { - return switch (status) { - case SETTLEMENT_CONFIRMED, SETTLING, SUBMITTED_FOR_SETTLEMENT, SETTLED -> PaymentStatus.SUCCEEDED; - case AUTHORIZATION_EXPIRED, GATEWAY_REJECTED, PROCESSOR_DECLINED, SETTLEMENT_DECLINED, VOIDED, FAILED -> - PaymentStatus.FAILED; - default -> PaymentStatus.UNKNOWN; - }; - } - - private static PaymentStatus getPaymentStatus(com.braintree.graphql.client.type.PaymentStatus status) { - try { - Transaction.Status transactionStatus = Transaction.Status.valueOf(status.rawValue); - - return getPaymentStatus(transactionStatus); - } catch (final Exception e) { - return PaymentStatus.UNKNOWN; - } - } - - private static SubscriptionStatus getSubscriptionStatus(final Subscription.Status status) { - return switch (status) { - case ACTIVE -> SubscriptionStatus.ACTIVE; - case CANCELED, EXPIRED -> SubscriptionStatus.CANCELED; - case PAST_DUE -> SubscriptionStatus.PAST_DUE; - case PENDING -> SubscriptionStatus.INCOMPLETE; - case UNRECOGNIZED -> { - logger.error("Subscription has unrecognized status; library may need to be updated: {}", status); - yield SubscriptionStatus.UNKNOWN; - } - }; - } - - private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) { - return switch (currency.toLowerCase(Locale.ROOT)) { - // JPY is the only supported zero-decimal currency - case "jpy" -> BigDecimal.valueOf(amount); - default -> BigDecimal.valueOf(amount).scaleByPowerOfTen(-2); - }; - } - - public record PayPalOneTimePaymentApprovalDetails(String approvalUrl, String paymentId) { - - } - - public record PayPalChargeSuccessDetails(String paymentId) { - - } - - private void assertResultSuccess(Result result) throws CompletionException { - if (!result.isSuccess()) { - throw new CompletionException(new BraintreeException(result.getMessage())); - } - } - - @Override - public CompletableFuture createCustomer(final byte[] subscriberUser) { - return CompletableFuture.supplyAsync(() -> { - final CustomerRequest request = new CustomerRequest() - .customField("subscriber_user", HexFormat.of().formatHex(subscriberUser)); - try { - return braintreeGateway.customer().create(request); - } catch (BraintreeException e) { - throw new CompletionException(e); - } - }, executor) - .thenApply(result -> { - assertResultSuccess(result); - - return new ProcessorCustomer(result.getTarget().getId(), SubscriptionProcessor.BRAINTREE); - }); - - } - - @Override - public CompletableFuture createPaymentMethodSetupToken(final String customerId) { - return CompletableFuture.supplyAsync(() -> { - ClientTokenRequest request = new ClientTokenRequest() - .customerId(customerId); - - return braintreeGateway.clientToken().generate(request); - }, executor); - } - - @Override - public CompletableFuture setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken, - @Nullable String currentSubscriptionId) { - final Optional maybeSubscriptionId = Optional.ofNullable(currentSubscriptionId); - return braintreeGraphqlClient.tokenizePayPalBillingAgreement(billingAgreementToken) - .thenCompose(tokenizePayPalBillingAgreement -> - braintreeGraphqlClient.vaultPaymentMethod(customerId, tokenizePayPalBillingAgreement.paymentMethod.id)) - .thenApplyAsync(vaultPaymentMethod -> braintreeGateway.customer() - .update(customerId, new CustomerRequest() - .defaultPaymentMethodToken(vaultPaymentMethod.paymentMethod.id)), - executor) - .thenAcceptAsync(result -> { - maybeSubscriptionId.ifPresent( - subscriptionId -> braintreeGateway.subscription() - .update(subscriptionId, new SubscriptionRequest() - .paymentMethodToken(result.getTarget().getDefaultPaymentMethod().getToken()))); - }, executor); - } - - @Override - public CompletableFuture getSubscription(String subscriptionId) { - return CompletableFuture.supplyAsync(() -> braintreeGateway.subscription().find(subscriptionId), executor); - } - - @Override - public CompletableFuture createSubscription(String customerId, String planId, long level, - long lastSubscriptionCreatedAt) { - - return getDefaultPaymentMethod(customerId) - .thenCompose(paymentMethod -> { - if (paymentMethod == null) { - throw new ClientErrorException(Response.Status.CONFLICT); - } - - final Optional maybeExistingSubscription = paymentMethod.getSubscriptions().stream() - .filter(sub -> sub.getStatus().equals(Subscription.Status.ACTIVE)) - .filter(Subscription::neverExpires) - .findAny(); - - return maybeExistingSubscription.map(subscription -> findPlan(subscription.getPlanId()) - .thenApply(plan -> { - if (getLevelForPlan(plan) != level) { - // if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption) - // with a different level. - // In this case, it’s safer and easier to recover by returning this subscription, rather than - // returning an error - logger.warn("existing subscription had unexpected level"); - } - return subscription; - })) - .orElseGet(() -> findPlan(planId).thenApplyAsync(plan -> { - final Result result = braintreeGateway.subscription().create(new SubscriptionRequest() - .planId(planId) - .paymentMethodToken(paymentMethod.getToken()) - .merchantAccountId( - currenciesToMerchantAccounts.get(plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT))) - .options() - .startImmediately(true) - .done() - ); - - assertResultSuccess(result); - - return result.getTarget(); - })); - }).thenApply(subscription -> new SubscriptionId(subscription.getId())); - } - - private CompletableFuture getDefaultPaymentMethod(String customerId) { - return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId).getDefaultPaymentMethod(), - executor); - } - - - @Override - public CompletableFuture updateSubscription(Object subscriptionObj, String planId, long level, - String idempotencyKey) { - - if (!(subscriptionObj instanceof final Subscription subscription)) { - throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName()); - } - - // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and - // and not prorated. Braintree subscriptions cannot change their next billing date, - // so we must end the existing one and create a new one - return cancelSubscriptionAtEndOfCurrentPeriod(subscription) - .thenCompose(ignored -> { - - final Transaction transaction = getLatestTransactionForSubscription(subscription).orElseThrow( - () -> new ClientErrorException( - Response.Status.CONFLICT)); - - final Customer customer = transaction.getCustomer(); - - return createSubscription(customer.getId(), planId, level, - subscription.getCreatedAt().toInstant().getEpochSecond()); - }); - } - - @Override - public CompletableFuture getLevelAndCurrencyForSubscription(Object subscriptionObj) { - final Subscription subscription = getSubscription(subscriptionObj); - - return findPlan(subscription.getPlanId()) - .thenApply( - plan -> new LevelAndCurrency(getLevelForPlan(plan), plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT))); - - } - - private CompletableFuture findPlan(String planId) { - return CompletableFuture.supplyAsync(() -> braintreeGateway.plan().find(planId), executor); - } - - private long getLevelForPlan(final Plan plan) { - final BraintreePlanMetadata metadata; - try { - metadata = new ObjectMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class); - - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - - return metadata.level(); - } - - @Override - public CompletableFuture getSubscriptionInformation(Object subscriptionObj) { - final Subscription subscription = getSubscription(subscriptionObj); - - return CompletableFuture.supplyAsync(() -> { - - final Plan plan = braintreeGateway.plan().find(subscription.getPlanId()); - - final long level = getLevelForPlan(plan); - - final Instant anchor = subscription.getFirstBillingDate().toInstant(); - final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant(); - - final Optional maybeTransaction = getLatestTransactionForSubscription(subscription); - - final ChargeFailure chargeFailure = maybeTransaction.map(transaction -> { - - if (getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { - return null; - } - - final String code; - final String message; - if (transaction.getProcessorResponseCode() != null) { - code = transaction.getProcessorResponseCode(); - message = transaction.getProcessorResponseText(); - } else if (transaction.getGatewayRejectionReason() != null) { - code = "gateway"; - message = transaction.getGatewayRejectionReason().toString(); - } else { - code = "unknown"; - message = "unknown"; - } - - return new ChargeFailure( - code, - message, - null, - null, - null); - - }).orElse(null); - - - return new SubscriptionInformation( - new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT), - SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())), - level, - anchor, - endOfCurrentPeriod, - Subscription.Status.ACTIVE == subscription.getStatus(), - !subscription.neverExpires(), - getSubscriptionStatus(subscription.getStatus()), - chargeFailure - ); - }, executor); - } - - @Override - public CompletableFuture cancelAllActiveSubscriptions(String customerId) { - - return CompletableFuture.supplyAsync(() -> braintreeGateway.customer().find(customerId), executor).thenCompose(customer -> { - - final List> subscriptionCancelFutures = customer.getDefaultPaymentMethod().getSubscriptions().stream() - .map(this::cancelSubscriptionAtEndOfCurrentPeriod) - .toList(); - - return CompletableFuture.allOf(subscriptionCancelFutures.toArray(new CompletableFuture[0])); - }); - } - - private CompletableFuture cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) { - return CompletableFuture.supplyAsync(() -> { - braintreeGateway.subscription().update(subscription.getId(), - new SubscriptionRequest().numberOfBillingCycles(subscription.getCurrentBillingCycle())); - return null; - }, executor); - } - - - @Override - public CompletableFuture getReceiptItem(String subscriptionId) { - - return getLatestTransactionForSubscription(subscriptionId).thenApply(maybeTransaction -> maybeTransaction.map(transaction -> { - - if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) { - throw new WebApplicationException(Response.Status.PAYMENT_REQUIRED); - } - - final Instant expiration = transaction.getSubscriptionDetails().getBillingPeriodEndDate().toInstant(); - final Plan plan = braintreeGateway.plan().find(transaction.getPlanId()); - - final BraintreePlanMetadata metadata; - try { - metadata = new ObjectMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class); - - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - - return new ReceiptItem(transaction.getId(), expiration, metadata.level()); - - }).orElseThrow(() -> new WebApplicationException(Response.Status.NO_CONTENT))); - } - - private static Subscription getSubscription(Object subscriptionObj) { - if (!(subscriptionObj instanceof final Subscription subscription)) { - throw new IllegalArgumentException("Invalid subscription object: " + subscriptionObj.getClass().getName()); - } - return subscription; - } - - public CompletableFuture> getLatestTransactionForSubscription(String subscriptionId) { - return getSubscription(subscriptionId) - .thenApply(BraintreeManager::getSubscription) - .thenApply(this::getLatestTransactionForSubscription); - } - - private Optional getLatestTransactionForSubscription(Subscription subscription) { - return subscription.getTransactions().stream() - .max(Comparator.comparing(Transaction::getCreatedAt)); - } - - public CompletableFuture createPayPalBillingAgreement(final String returnUrl, - final String cancelUrl, final String locale) { - return braintreeGraphqlClient.createPayPalBillingAgreement(returnUrl, cancelUrl, locale) - .thenApply(response -> - new PayPalBillingAgreementApprovalDetails((String) response.approvalUrl, response.billingAgreementToken) - ); - } - - public record PayPalBillingAgreementApprovalDetails(String approvalUrl, String billingAgreementToken) { - - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreePlanMetadata.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreePlanMetadata.java deleted file mode 100644 index ab60cf699..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreePlanMetadata.java +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -public record BraintreePlanMetadata(long level) { - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java deleted file mode 100644 index e472a8bfe..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -public enum PaymentMethod { - /** - * A credit card or debit card, including those from Apple Pay and Google Pay - */ - CARD, - /** - * A PayPal account - */ - PAYPAL, -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ProcessorCustomer.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ProcessorCustomer.java deleted file mode 100644 index 73c5d6b46..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ProcessorCustomer.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import java.nio.charset.StandardCharsets; -import org.whispersystems.dispatch.util.Util; - -public record ProcessorCustomer(String customerId, SubscriptionProcessor processor) { - - public byte[] toDynamoBytes() { - return Util.combine(new byte[]{processor.getId()}, customerId.getBytes(StandardCharsets.UTF_8)); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java deleted file mode 100644 index d5f313206..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java +++ /dev/null @@ -1,593 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import com.google.common.base.Strings; -import com.google.common.collect.Lists; -import com.stripe.exception.StripeException; -import com.stripe.model.Charge; -import com.stripe.model.Customer; -import com.stripe.model.Invoice; -import com.stripe.model.InvoiceLineItem; -import com.stripe.model.PaymentIntent; -import com.stripe.model.Price; -import com.stripe.model.Product; -import com.stripe.model.SetupIntent; -import com.stripe.model.Subscription; -import com.stripe.model.SubscriptionItem; -import com.stripe.net.RequestOptions; -import com.stripe.param.CustomerCreateParams; -import com.stripe.param.CustomerRetrieveParams; -import com.stripe.param.CustomerUpdateParams; -import com.stripe.param.CustomerUpdateParams.InvoiceSettings; -import com.stripe.param.InvoiceListParams; -import com.stripe.param.PaymentIntentCreateParams; -import com.stripe.param.PriceRetrieveParams; -import com.stripe.param.SetupIntentCreateParams; -import com.stripe.param.SubscriptionCancelParams; -import com.stripe.param.SubscriptionCreateParams; -import com.stripe.param.SubscriptionListParams; -import com.stripe.param.SubscriptionRetrieveParams; -import com.stripe.param.SubscriptionUpdateParams; -import com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor; -import com.stripe.param.SubscriptionUpdateParams.ProrationBehavior; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HexFormat; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.Executor; -import java.util.function.Consumer; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import javax.ws.rs.InternalServerErrorException; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.util.Conversions; - -public class StripeManager implements SubscriptionProcessorManager { - - private static final String METADATA_KEY_LEVEL = "level"; - - private final String apiKey; - private final Executor executor; - private final byte[] idempotencyKeyGenerator; - private final String boostDescription; - private final Set supportedCurrencies; - - public StripeManager( - @Nonnull String apiKey, - @Nonnull Executor executor, - @Nonnull byte[] idempotencyKeyGenerator, - @Nonnull String boostDescription, - @Nonnull Set supportedCurrencies) { - this.apiKey = Objects.requireNonNull(apiKey); - if (Strings.isNullOrEmpty(apiKey)) { - throw new IllegalArgumentException("apiKey cannot be empty"); - } - this.executor = Objects.requireNonNull(executor); - this.idempotencyKeyGenerator = Objects.requireNonNull(idempotencyKeyGenerator); - if (idempotencyKeyGenerator.length == 0) { - throw new IllegalArgumentException("idempotencyKeyGenerator cannot be empty"); - } - this.boostDescription = Objects.requireNonNull(boostDescription); - this.supportedCurrencies = supportedCurrencies; - } - - @Override - public SubscriptionProcessor getProcessor() { - return SubscriptionProcessor.STRIPE; - } - - @Override - public boolean supportsPaymentMethod(PaymentMethod paymentMethod) { - return paymentMethod == PaymentMethod.CARD; - } - - @Override - public boolean supportsCurrency(final String currency) { - return supportedCurrencies.contains(currency); - } - - private RequestOptions commonOptions() { - return commonOptions(null); - } - - private RequestOptions commonOptions(@Nullable String idempotencyKey) { - return RequestOptions.builder() - .setIdempotencyKey(idempotencyKey) - .setApiKey(apiKey) - .build(); - } - - @Override - public CompletableFuture createCustomer(byte[] subscriberUser) { - return CompletableFuture.supplyAsync(() -> { - CustomerCreateParams params = CustomerCreateParams.builder() - .putMetadata("subscriberUser", HexFormat.of().formatHex(subscriberUser)) - .build(); - try { - return Customer.create(params, commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser))); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor) - .thenApply(customer -> new ProcessorCustomer(customer.getId(), getProcessor())); - } - - public CompletableFuture getCustomer(String customerId) { - return CompletableFuture.supplyAsync(() -> { - CustomerRetrieveParams params = CustomerRetrieveParams.builder().build(); - try { - return Customer.retrieve(customerId, params, commonOptions()); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - @Override - public CompletableFuture setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId, - @Nullable String currentSubscriptionId) { - return CompletableFuture.supplyAsync(() -> { - Customer customer = new Customer(); - customer.setId(customerId); - CustomerUpdateParams params = CustomerUpdateParams.builder() - .setInvoiceSettings(InvoiceSettings.builder() - .setDefaultPaymentMethod(paymentMethodId) - .build()) - .build(); - try { - customer.update(params, commonOptions()); - return null; - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - @Override - public CompletableFuture createPaymentMethodSetupToken(String customerId) { - return CompletableFuture.supplyAsync(() -> { - SetupIntentCreateParams params = SetupIntentCreateParams.builder() - .setCustomer(customerId) - .build(); - try { - return SetupIntent.create(params, commonOptions()); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor) - .thenApply(SetupIntent::getClientSecret); - } - - @Override - public Set getSupportedCurrencies() { - return supportedCurrencies; - } - - /** - * Creates a payment intent. May throw a 400 WebApplicationException if the amount is too small. - */ - public CompletableFuture createPaymentIntent(String currency, long amount, long level) { - return CompletableFuture.supplyAsync(() -> { - PaymentIntentCreateParams params = PaymentIntentCreateParams.builder() - .setAmount(amount) - .setCurrency(currency.toLowerCase(Locale.ROOT)) - .setDescription(boostDescription) - .putMetadata("level", Long.toString(level)) - .build(); - try { - return PaymentIntent.create(params, commonOptions()); - } catch (StripeException e) { - if ("amount_too_small".equalsIgnoreCase(e.getCode())) { - throw new WebApplicationException(Response - .status(Status.BAD_REQUEST) - .entity(Map.of("error", "amount_too_small")) - .build()); - } else { - throw new CompletionException(e); - } - } - }, executor); - } - - public CompletableFuture getPaymentDetails(String paymentIntentId) { - return CompletableFuture.supplyAsync(() -> { - try { - final PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentId, commonOptions()); - - return new PaymentDetails(paymentIntent.getId(), - paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(), - getPaymentStatusForStatus(paymentIntent.getStatus()), - Instant.ofEpochSecond(paymentIntent.getCreated())); - } catch (StripeException e) { - if (e.getStatusCode() == 404) { - return null; - } else { - throw new CompletionException(e); - } - } - }, executor); - } - - private static PaymentStatus getPaymentStatusForStatus(String status) { - return switch (status.toLowerCase(Locale.ROOT)) { - case "processing" -> PaymentStatus.PROCESSING; - case "succeeded" -> PaymentStatus.SUCCEEDED; - default -> PaymentStatus.UNKNOWN; - }; - } - - private static SubscriptionStatus getSubscriptionStatus(final String status) { - return SubscriptionStatus.forApiValue(status); - } - - @Override - public CompletableFuture createSubscription(String customerId, String priceId, long level, - long lastSubscriptionCreatedAt) { - // this relies on Stripe's idempotency key to avoid creating more than one subscription if the client - // retries this request - return CompletableFuture.supplyAsync(() -> { - SubscriptionCreateParams params = SubscriptionCreateParams.builder() - .setCustomer(customerId) - .setOffSession(true) - .setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE) - .addItem(SubscriptionCreateParams.Item.builder() - .setPrice(priceId) - .build()) - .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) - .build(); - try { - // the idempotency key intentionally excludes priceId - // - // If the client tells the server several times in a row before the initial creation of a subscription to - // create a subscription, we want to ensure only one gets created. - return Subscription.create(params, commonOptions(generateIdempotencyKeyForCreateSubscription( - customerId, lastSubscriptionCreatedAt))); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor) - .thenApply(subscription -> new SubscriptionId(subscription.getId())); - } - - @Override - public CompletableFuture updateSubscription( - Object subscriptionObj, String priceId, long level, String idempotencyKey) { - - final Subscription subscription = getSubscription(subscriptionObj); - - return CompletableFuture.supplyAsync(() -> { - List items = new ArrayList<>(); - for (final SubscriptionItem item : subscription.getItems().autoPagingIterable(null, commonOptions())) { - items.add(SubscriptionUpdateParams.Item.builder() - .setId(item.getId()) - .setDeleted(true) - .build()); - } - items.add(SubscriptionUpdateParams.Item.builder() - .setPrice(priceId) - .build()); - SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() - .putMetadata(METADATA_KEY_LEVEL, Long.toString(level)) - - // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and - // not prorated - .setProrationBehavior(ProrationBehavior.NONE) - .setBillingCycleAnchor(BillingCycleAnchor.NOW) - .setOffSession(true) - .setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE) - .addAllItem(items) - .build(); - try { - return subscription.update(params, commonOptions(generateIdempotencyKeyForSubscriptionUpdate( - subscription.getCustomer(), idempotencyKey))); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor) - .thenApply(subscription1 -> new SubscriptionId(subscription1.getId())); - } - - public CompletableFuture getSubscription(String subscriptionId) { - return CompletableFuture.supplyAsync(() -> { - SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder() - .addExpand("latest_invoice") - .addExpand("latest_invoice.charge") - .build(); - try { - return Subscription.retrieve(subscriptionId, params, commonOptions()); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - public CompletableFuture cancelAllActiveSubscriptions(String customerId) { - return getCustomer(customerId).thenCompose(customer -> { - if (customer == null) { - throw new InternalServerErrorException( - "no customer record found for id " + customerId); - } - return listNonCanceledSubscriptions(customer); - }).thenCompose(subscriptions -> { - @SuppressWarnings("unchecked") - CompletableFuture[] futures = (CompletableFuture[]) subscriptions.stream() - .map(this::cancelSubscriptionAtEndOfCurrentPeriod).toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(futures); - }); - } - - public CompletableFuture> listNonCanceledSubscriptions(Customer customer) { - return CompletableFuture.supplyAsync(() -> { - SubscriptionListParams params = SubscriptionListParams.builder() - .setCustomer(customer.getId()) - .build(); - try { - return Lists.newArrayList(Subscription.list(params, commonOptions()).autoPagingIterable(null, commonOptions())); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - public CompletableFuture cancelSubscriptionImmediately(Subscription subscription) { - return CompletableFuture.supplyAsync(() -> { - SubscriptionCancelParams params = SubscriptionCancelParams.builder().build(); - try { - return subscription.cancel(params, commonOptions()); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - public CompletableFuture cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) { - return CompletableFuture.supplyAsync(() -> { - SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() - .setCancelAtPeriodEnd(true) - .build(); - try { - return subscription.update(params, commonOptions()); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - public CompletableFuture> getItemsForSubscription(Subscription subscription) { - return CompletableFuture.supplyAsync( - () -> Lists.newArrayList(subscription.getItems().autoPagingIterable(null, commonOptions())), - executor); - } - - public CompletableFuture getPriceForSubscription(Subscription subscription) { - return getItemsForSubscription(subscription).thenApply(subscriptionItems -> { - if (subscriptionItems.isEmpty()) { - throw new IllegalStateException("no items found in subscription " + subscription.getId()); - } else if (subscriptionItems.size() > 1) { - throw new IllegalStateException( - "too many items found in subscription " + subscription.getId() + "; items=" + subscriptionItems.size()); - } else { - return subscriptionItems.stream().findAny().get().getPrice(); - } - }); - } - - private CompletableFuture getProductForSubscription(Subscription subscription) { - return getPriceForSubscription(subscription).thenCompose(price -> getProductForPrice(price.getId())); - } - - @Override - public CompletableFuture getLevelAndCurrencyForSubscription(Object subscriptionObj) { - final Subscription subscription = getSubscription(subscriptionObj); - - return getProductForSubscription(subscription).thenApply( - product -> new LevelAndCurrency(getLevelForProduct(product), subscription.getCurrency().toLowerCase( - Locale.ROOT))); - } - - public CompletableFuture getLevelForPrice(Price price) { - return getProductForPrice(price.getId()).thenApply(this::getLevelForProduct); - } - - public CompletableFuture getProductForPrice(String priceId) { - return CompletableFuture.supplyAsync(() -> { - PriceRetrieveParams params = PriceRetrieveParams.builder().addExpand("product").build(); - try { - return Price.retrieve(priceId, params, commonOptions()).getProductObject(); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - public long getLevelForProduct(Product product) { - return Long.parseLong(product.getMetadata().get(METADATA_KEY_LEVEL)); - } - - /** - * Returns the paid invoices within the past 90 days for a subscription ordered by the creation date in descending - * order (latest first). - */ - public CompletableFuture> getPaidInvoicesForSubscription(String subscriptionId, Instant now) { - return CompletableFuture.supplyAsync(() -> { - InvoiceListParams params = InvoiceListParams.builder() - .setSubscription(subscriptionId) - .setStatus(InvoiceListParams.Status.PAID) - .setCreated(InvoiceListParams.Created.builder() - .setGte(now.minus(Duration.ofDays(90)).getEpochSecond()) - .build()) - .build(); - try { - ArrayList invoices = Lists.newArrayList(Invoice.list(params, commonOptions()) - .autoPagingIterable(null, commonOptions())); - invoices.sort(Comparator.comparingLong(Invoice::getCreated).reversed()); - return invoices; - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - @Override - public CompletableFuture getSubscriptionInformation(Object subscriptionObj) { - - final Subscription subscription = getSubscription(subscriptionObj); - - return getPriceForSubscription(subscription).thenCompose(price -> - getLevelForPrice(price).thenApply(level -> { - ChargeFailure chargeFailure = null; - - if (subscription.getLatestInvoiceObject() != null && subscription.getLatestInvoiceObject().getChargeObject() != null && - (subscription.getLatestInvoiceObject().getChargeObject().getFailureCode() != null || subscription.getLatestInvoiceObject().getChargeObject().getFailureMessage() != null)) { - Charge charge = subscription.getLatestInvoiceObject().getChargeObject(); - Charge.Outcome outcome = charge.getOutcome(); - chargeFailure = new ChargeFailure( - charge.getFailureCode(), - charge.getFailureMessage(), - outcome != null ? outcome.getNetworkStatus() : null, - outcome != null ? outcome.getReason() : null, - outcome != null ? outcome.getType() : null); - } - - return new SubscriptionInformation( - new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()), - level, - Instant.ofEpochSecond(subscription.getBillingCycleAnchor()), - Instant.ofEpochSecond(subscription.getCurrentPeriodEnd()), - Objects.equals(subscription.getStatus(), "active"), - subscription.getCancelAtPeriodEnd(), - getSubscriptionStatus(subscription.getStatus()), - chargeFailure - ); - })); - } - - private Subscription getSubscription(Object subscriptionObj) { - if (!(subscriptionObj instanceof final Subscription subscription)) { - throw new IllegalArgumentException("invalid subscription object: " + subscriptionObj.getClass().getName()); - } - - return subscription; - } - - @Override - public CompletableFuture getReceiptItem(String subscriptionId) { - return getLatestInvoiceForSubscription(subscriptionId) - .thenCompose(invoice -> convertInvoiceToReceipt(invoice, subscriptionId)); - } - - public CompletableFuture getLatestInvoiceForSubscription(String subscriptionId) { - return CompletableFuture.supplyAsync(() -> { - SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder() - .addExpand("latest_invoice") - .build(); - try { - return Subscription.retrieve(subscriptionId, params, commonOptions()).getLatestInvoiceObject(); - } catch (StripeException e) { - throw new CompletionException(e); - } - }, executor); - } - - private CompletableFuture convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId) { - if (latestSubscriptionInvoice == null) { - throw new WebApplicationException(Status.NO_CONTENT); - } - if (StringUtils.equalsIgnoreCase("open", latestSubscriptionInvoice.getStatus())) { - throw new WebApplicationException(Status.NO_CONTENT); - } - if (!StringUtils.equalsIgnoreCase("paid", latestSubscriptionInvoice.getStatus())) { - throw new WebApplicationException(Status.PAYMENT_REQUIRED); - } - - return getInvoiceLineItemsForInvoice(latestSubscriptionInvoice).thenCompose(invoiceLineItems -> { - Collection subscriptionLineItems = invoiceLineItems.stream() - .filter(invoiceLineItem -> Objects.equals("subscription", invoiceLineItem.getType())) - .toList(); - if (subscriptionLineItems.isEmpty()) { - throw new IllegalStateException("latest subscription invoice has no subscription line items; subscriptionId=" - + subscriptionId + "; invoiceId=" + latestSubscriptionInvoice.getId()); - } - if (subscriptionLineItems.size() > 1) { - throw new IllegalStateException( - "latest subscription invoice has too many subscription line items; subscriptionId=" + subscriptionId - + "; invoiceId=" + latestSubscriptionInvoice.getId() + "; count=" + subscriptionLineItems.size()); - } - - InvoiceLineItem subscriptionLineItem = subscriptionLineItems.stream().findAny().get(); - return getReceiptForSubscriptionInvoiceLineItem(subscriptionLineItem); - }); - } - - private CompletableFuture getReceiptForSubscriptionInvoiceLineItem(InvoiceLineItem subscriptionLineItem) { - return getProductForPrice(subscriptionLineItem.getPrice().getId()).thenApply(product -> new ReceiptItem( - subscriptionLineItem.getId(), - Instant.ofEpochSecond(subscriptionLineItem.getPeriod().getEnd()), - getLevelForProduct(product))); - } - - public CompletableFuture> getInvoiceLineItemsForInvoice(Invoice invoice) { - return CompletableFuture.supplyAsync( - () -> Lists.newArrayList(invoice.getLines().autoPagingIterable(null, commonOptions())), executor); - } - - /** - * We use a client generated idempotency key for subscription updates due to not being able to distinguish between a - * call to update to level 2, then back to level 1, then back to level 2. If this all happens within Stripe's - * idempotency window the subsequent update call would not happen unless we get some indication from the client that - * it is intentionally sending a repeat of the update to level 2 request because user is changing again, so in this - * case we derive idempotency from the client. - */ - private String generateIdempotencyKeyForSubscriptionUpdate(String customerId, String idempotencyKey) { - return generateIdempotencyKey("subscriptionUpdate", mac -> { - mac.update(customerId.getBytes(StandardCharsets.UTF_8)); - mac.update(idempotencyKey.getBytes(StandardCharsets.UTF_8)); - }); - } - - private String generateIdempotencyKeyForSubscriberUser(byte[] subscriberUser) { - return generateIdempotencyKey("subscriberUser", mac -> mac.update(subscriberUser)); - } - - private String generateIdempotencyKeyForCreateSubscription(String customerId, long lastSubscriptionCreatedAt) { - return generateIdempotencyKey("customerId", mac -> { - mac.update(customerId.getBytes(StandardCharsets.UTF_8)); - mac.update(Conversions.longToByteArray(lastSubscriptionCreatedAt)); - }); - } - - private String generateIdempotencyKey(String type, Consumer byteConsumer) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(idempotencyKeyGenerator, "HmacSHA256")); - mac.update(type.getBytes(StandardCharsets.UTF_8)); - byteConsumer.accept(mac); - return Base64.getUrlEncoder().encodeToString(mac.doFinal()); - } catch (NoSuchAlgorithmException | InvalidKeyException e) { - throw new AssertionError(e); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java deleted file mode 100644 index d3c943cac..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import java.math.BigDecimal; -import java.util.Locale; -import java.util.Set; - -/** - * Utility for scaling amounts among Stripe, Braintree, configuration, and API responses. - *

- * In general, the API input and output follow’s Stripe’s specification to use amounts in a currency’s - * smallest unit. The exception is configuration APIs, which return values in the currency’s primary unit. Braintree - * uses the currency’s primary unit for its input and output. - *

Examples

- * - * - * API - * - * - * - * - * - * - * - * - * - *
Currency, AmountStripeBraintree
USD 4.994994994.99
JPY 501501501501
- */ -public class SubscriptionCurrencyUtil { - - // This list was taken from https://stripe.com/docs/currencies?presentment-currency=US - // Braintree - private static final Set stripeZeroDecimalCurrencies = Set.of("bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", - "mga", "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf"); - - - /** - * Takes an amount as configured and turns it into an amount as API clients (and Stripe) expect to see it. For - * instance, {@code USD 4.99} return {@code 499}, while {@code JPY 500} returns {@code 500}. - * - *

- * Stripe appears to only support zero- and two-decimal currencies, but also has some backwards compatibility issues - * with 0 decimal currencies, so this is not to any ISO standard but rather directly from Stripe's API doc page. - */ - public static BigDecimal convertConfiguredAmountToApiAmount(String currency, BigDecimal configuredAmount) { - if (stripeZeroDecimalCurrencies.contains(currency.toLowerCase(Locale.ROOT))) { - return configuredAmount; - } - - return configuredAmount.scaleByPowerOfTen(2); - } - - /** - * @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String, - * BigDecimal) - */ - public static BigDecimal convertConfiguredAmountToStripeAmount(String currency, BigDecimal configuredAmount) { - return convertConfiguredAmountToApiAmount(currency, configuredAmount); - } - - /** - * Braintree’s API expects amounts in a currency’s primary unit (e.g. USD 4.99) - * - * @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String, - * BigDecimal) - */ - static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) { - return convertConfiguredAmountToApiAmount(currency, amount); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessor.java deleted file mode 100644 index a76dc8593..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessor.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; - -/** - * A set of payment providers used for donations - */ -public enum SubscriptionProcessor { - // because provider IDs are stored, they should not be reused, and great care - // must be used if a provider is removed from the list - STRIPE(1), - BRAINTREE(2), - ; - - private static final Map IDS_TO_PROCESSORS = new HashMap<>(); - - static { - Arrays.stream(SubscriptionProcessor.values()) - .forEach(provider -> IDS_TO_PROCESSORS.put((int) provider.id, provider)); - } - - /** - * @return the provider associated with the given ID, or {@code null} if none exists - */ - public static SubscriptionProcessor forId(byte id) { - return IDS_TO_PROCESSORS.get((int) id); - } - - private final byte id; - - SubscriptionProcessor(int id) { - if (id > 255) { - throw new IllegalArgumentException("ID must fit in one byte: " + id); - } - - this.id = (byte) id; - } - - public byte getId() { - return id; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java deleted file mode 100644 index 12c28a284..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorManager.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import java.math.BigDecimal; -import java.time.Instant; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public interface SubscriptionProcessorManager { - SubscriptionProcessor getProcessor(); - - boolean supportsPaymentMethod(PaymentMethod paymentMethod); - - boolean supportsCurrency(String currency); - - Set getSupportedCurrencies(); - - CompletableFuture getPaymentDetails(String paymentId); - - CompletableFuture createCustomer(byte[] subscriberUser); - - CompletableFuture createPaymentMethodSetupToken(String customerId); - - - /** - * @param customerId - * @param paymentMethodToken a processor-specific token necessary - * @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update - * @return - */ - CompletableFuture setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken, - @Nullable String currentSubscriptionId); - - CompletableFuture getSubscription(String subscriptionId); - - CompletableFuture createSubscription(String customerId, String templateId, long level, - long lastSubscriptionCreatedAt); - - CompletableFuture updateSubscription( - Object subscription, String templateId, long level, String idempotencyKey); - - /** - * @param subscription - * @return the subscription’s current level and lower-case currency code - */ - CompletableFuture getLevelAndCurrencyForSubscription(Object subscription); - - CompletableFuture cancelAllActiveSubscriptions(String customerId); - - CompletableFuture getReceiptItem(String subscriptionId); - - CompletableFuture getSubscriptionInformation(Object subscription); - - record PaymentDetails(String id, - Map customMetadata, - PaymentStatus status, - Instant created) { - - } - - enum PaymentStatus { - SUCCEEDED, - PROCESSING, - FAILED, - UNKNOWN, - } - - enum SubscriptionStatus { - /** - * The subscription is in good standing and the most recent payment was successful. - */ - ACTIVE("active"), - - /** - * Payment failed when creating the subscription, or the subscription’s start date is in the future. - */ - INCOMPLETE("incomplete"), - - /** - * Payment on the latest renewal either failed or wasn't attempted. - */ - PAST_DUE("past_due"), - - /** - * The subscription has been canceled. - */ - CANCELED("canceled"), - - /** - * The latest renewal hasn't been paid but the subscription remains in place. - */ - UNPAID("unpaid"), - - /** - * The status from the downstream processor is unknown. - */ - UNKNOWN("unknown"); - - - private final String apiValue; - - SubscriptionStatus(String apiValue) { - this.apiValue = apiValue; - } - - public static SubscriptionStatus forApiValue(String status) { - return switch (status) { - case "active" -> ACTIVE; - case "canceled", "incomplete_expired" -> CANCELED; - case "unpaid" -> UNPAID; - case "past_due" -> PAST_DUE; - case "incomplete" -> INCOMPLETE; - - case "trialing" -> { - final Logger logger = LoggerFactory.getLogger(SubscriptionProcessorManager.class); - logger.error("Subscription has status that should never happen: {}", status); - - yield UNKNOWN; - } - default -> { - final Logger logger = LoggerFactory.getLogger(SubscriptionProcessorManager.class); - logger.error("Subscription has unknown status: {}", status); - - yield UNKNOWN; - } - }; - } - - public String getApiValue() { - return apiValue; - } - } - - - record SubscriptionId(String id) { - - } - - record SubscriptionInformation(SubscriptionPrice price, long level, Instant billingCycleAnchor, - Instant endOfCurrentPeriod, boolean active, boolean cancelAtPeriodEnd, - SubscriptionStatus status, - ChargeFailure chargeFailure) { - - } - - record SubscriptionPrice(String currency, BigDecimal amount) { - - } - - record ChargeFailure(String code, String message, @Nullable String outcomeNetworkStatus, - @Nullable String outcomeReason, @Nullable String outcomeType) { - - } - - record ReceiptItem(String itemId, Instant expiration, long level) { - - } - - record LevelAndCurrency(long level, String currency) { - - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java deleted file mode 100644 index 494703e00..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.nio.ByteBuffer; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -/** AwsAV provides static helper methods for working with AWS AttributeValues. */ -public class AttributeValues { - - // Clear-type methods - - public static AttributeValue b(byte[] value) { - return AttributeValue.builder().b(SdkBytes.fromByteArray(value)).build(); - } - - public static AttributeValue b(ByteBuffer value) { - return AttributeValue.builder().b(SdkBytes.fromByteBuffer(value)).build(); - } - - public static AttributeValue b(UUID value) { - return b(UUIDUtil.toByteBuffer(value)); - } - - public static AttributeValue n(long value) { - return AttributeValue.builder().n(String.valueOf(value)).build(); - } - - public static AttributeValue s(String value) { - return AttributeValue.builder().s(value).build(); - } - - public static AttributeValue m(Map value) { - return AttributeValue.builder().m(value).build(); - } - - // More opinionated methods - - public static AttributeValue fromString(String value) { - return AttributeValue.builder().s(value).build(); - } - - public static AttributeValue fromLong(long value) { - return AttributeValue.builder().n(Long.toString(value)).build(); - } - - public static AttributeValue fromBool(boolean value) { return AttributeValue.builder().bool(value).build(); } - - public static AttributeValue fromInt(int value) { - return AttributeValue.builder().n(Integer.toString(value)).build(); - } - - public static AttributeValue fromByteArray(byte[] value) { - return AttributeValues.fromSdkBytes(SdkBytes.fromByteArray(value)); - } - - public static AttributeValue fromByteBuffer(ByteBuffer value) { - return AttributeValues.fromSdkBytes(SdkBytes.fromByteBuffer(value)); - } - - public static AttributeValue fromUUID(UUID uuid) { - return AttributeValues.fromSdkBytes(SdkBytes.fromByteArrayUnsafe(UUIDUtil.toBytes(uuid))); - } - - public static AttributeValue fromSdkBytes(SdkBytes value) { - return AttributeValue.builder().b(value).build(); - } - - private static boolean toBool(AttributeValue av) { - return av.bool(); - } - - private static int toInt(AttributeValue av) { - return Integer.parseInt(av.n()); - } - - private static long toLong(AttributeValue av) { - return Long.parseLong(av.n()); - } - - private static UUID toUUID(AttributeValue av) { - return UUIDUtil.fromBytes(av.b().asByteArrayUnsafe()); // We're guaranteed not to modify the byte array - } - - private static byte[] toByteArray(AttributeValue av) { - return av.b().asByteArray(); - } - - private static String toString(AttributeValue av) { - return av.s(); - } - - public static Optional get(Map item, String key) { - return Optional.ofNullable(item.get(key)); - } - - public static boolean getBool(Map item, String key, boolean defaultValue) { - return AttributeValues.get(item, key).map(AttributeValues::toBool).orElse(defaultValue); - } - - public static int getInt(Map item, String key, int defaultValue) { - return AttributeValues.get(item, key).map(AttributeValues::toInt).orElse(defaultValue); - } - - public static String getString(Map item, String key, String defaultValue) { - return AttributeValues.get(item, key).map(AttributeValues::toString).orElse(defaultValue); - } - - public static long getLong(Map item, String key, long defaultValue) { - return AttributeValues.get(item, key).map(AttributeValues::toLong).orElse(defaultValue); - } - - public static byte[] getByteArray(Map item, String key, byte[] defaultValue) { - return AttributeValues.get(item, key).map(AttributeValues::toByteArray).orElse(defaultValue); - } - - public static UUID getUUID(Map item, String key, UUID defaultValue) { - return AttributeValues.get(item, key).filter(av -> av.b() != null).map(AttributeValues::toUUID).orElse(defaultValue); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayAdapter.java deleted file mode 100644 index 201966723..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayAdapter.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.util; - - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import java.io.IOException; -import java.util.Base64; - -public class ByteArrayAdapter { - - public static class Serializing extends JsonSerializer { - @Override - public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) - throws IOException { - jsonGenerator.writeString(Base64.getEncoder().withoutPadding().encodeToString(bytes)); - } - } - - public static class Deserializing extends JsonDeserializer { - @Override - public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - return Base64.getDecoder().decode(jsonParser.getValueAsString()); - } - } -} - diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayBase64UrlAdapter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayBase64UrlAdapter.java deleted file mode 100644 index 1e3c2933a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayBase64UrlAdapter.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import java.io.IOException; -import java.util.Base64; - -public class ByteArrayBase64UrlAdapter { - public static class Serializing extends JsonSerializer { - @Override - public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) - throws IOException { - jsonGenerator.writeString(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)); - } - } - - public static class Deserializing extends JsonDeserializer { - @Override - public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { - return Base64.getUrlDecoder().decode(jsonParser.getValueAsString()); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/CertificateUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/CertificateUtil.java deleted file mode 100644 index 688351c34..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/CertificateUtil.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; - -public class CertificateUtil { - - public static KeyStore buildKeyStoreForPem(final String... caCertificatePems) throws CertificateException { - try { - final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - keyStore.load(null); - - for (int i = 0; i < caCertificatePems.length; i++) { - final X509Certificate certificate = getCertificate(caCertificatePems[i]); - - if (certificate == null) { - throw new CertificateException("No certificate found in parsing!"); - } - - keyStore.setCertificateEntry("ca-" + i, certificate); - } - - return keyStore; - } catch (IOException | KeyStoreException ex) { - throw new CertificateException(ex); - } catch (NoSuchAlgorithmException ex) { - throw new AssertionError(ex); - } - } - - public static X509Certificate getCertificate(final String certificatePem) throws CertificateException { - final CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); - - try (final ByteArrayInputStream pemInputStream = new ByteArrayInputStream(certificatePem.getBytes())) { - return (X509Certificate) certificateFactory.generateCertificate(pemInputStream); - } catch (IOException e) { - throw new CertificateException(e); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/CircuitBreakerUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/CircuitBreakerUtil.java deleted file mode 100644 index 043264ebe..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/CircuitBreakerUtil.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; - -import static com.codahale.metrics.MetricRegistry.name; -import io.github.resilience4j.circuitbreaker.CircuitBreaker; -import io.github.resilience4j.retry.Retry; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; - -public class CircuitBreakerUtil { - - private static final String CIRCUIT_BREAKER_CALL_COUNTER_NAME = name(CircuitBreakerUtil.class, "breaker", "call"); - private static final String CIRCUIT_BREAKER_STATE_GAUGE_NAME = name(CircuitBreakerUtil.class, "breaker", "state"); - private static final String RETRY_CALL_COUNTER_NAME = name(CircuitBreakerUtil.class, "retry", "call"); - - private static final String NAME_TAG_NAME = "name"; - private static final String OUTCOME_TAG_NAME = "outcome"; - - public static void registerMetrics(MetricRegistry metricRegistry, CircuitBreaker circuitBreaker, Class clazz) { - Meter successMeter = metricRegistry.meter(name(clazz, circuitBreaker.getName(), "success" )); - Meter failureMeter = metricRegistry.meter(name(clazz, circuitBreaker.getName(), "failure" )); - Meter unpermittedMeter = metricRegistry.meter(name(clazz, circuitBreaker.getName(), "unpermitted")); - - final String breakerName = clazz.getSimpleName() + "/" + circuitBreaker.getName(); - - final Counter successCounter = Metrics.counter(CIRCUIT_BREAKER_CALL_COUNTER_NAME, - NAME_TAG_NAME, breakerName, - OUTCOME_TAG_NAME, "success"); - - final Counter failureCounter = Metrics.counter(CIRCUIT_BREAKER_CALL_COUNTER_NAME, - NAME_TAG_NAME, breakerName, - OUTCOME_TAG_NAME, "failure"); - - final Counter unpermittedCounter = Metrics.counter(CIRCUIT_BREAKER_CALL_COUNTER_NAME, - NAME_TAG_NAME, breakerName, - OUTCOME_TAG_NAME, "unpermitted"); - - circuitBreaker.getEventPublisher().onSuccess(event -> { - successMeter.mark(); - successCounter.increment(); - }); - - circuitBreaker.getEventPublisher().onError(event -> { - failureMeter.mark(); - failureCounter.increment(); - }); - - circuitBreaker.getEventPublisher().onCallNotPermitted(event -> { - unpermittedMeter.mark(); - unpermittedCounter.increment(); - }); - - metricRegistry.gauge(name(clazz, circuitBreaker.getName(), "state"), () -> ()-> circuitBreaker.getState().getOrder()); - - Metrics.gauge(CIRCUIT_BREAKER_STATE_GAUGE_NAME, - Tags.of(Tag.of(NAME_TAG_NAME, circuitBreaker.getName())), - circuitBreaker, breaker -> breaker.getState().getOrder()); - } - - public static void registerMetrics(MetricRegistry metricRegistry, Retry retry, Class clazz) { - Meter successMeter = metricRegistry.meter(name(clazz, retry.getName(), "success" )); - Meter retryMeter = metricRegistry.meter(name(clazz, retry.getName(), "retry" )); - Meter errorMeter = metricRegistry.meter(name(clazz, retry.getName(), "error" )); - Meter ignoredErrorMeter = metricRegistry.meter(name(clazz, retry.getName(), "ignored_error")); - - final String retryName = clazz.getSimpleName() + "/" + retry.getName(); - - final Counter successCounter = Metrics.counter(RETRY_CALL_COUNTER_NAME, - NAME_TAG_NAME, retryName, - OUTCOME_TAG_NAME, "success"); - - final Counter retryCounter = Metrics.counter(RETRY_CALL_COUNTER_NAME, - NAME_TAG_NAME, retryName, - OUTCOME_TAG_NAME, "retry"); - - final Counter errorCounter = Metrics.counter(RETRY_CALL_COUNTER_NAME, - NAME_TAG_NAME, retryName, - OUTCOME_TAG_NAME, "error"); - - final Counter ignoredErrorCounter = Metrics.counter(RETRY_CALL_COUNTER_NAME, - NAME_TAG_NAME, retryName, - OUTCOME_TAG_NAME, "ignored_error"); - - retry.getEventPublisher().onSuccess(event -> { - successMeter.mark(); - successCounter.increment(); - }); - - retry.getEventPublisher().onRetry(event -> { - retryMeter.mark(); - retryCounter.increment(); - }); - - retry.getEventPublisher().onError(event -> { - errorMeter.mark(); - errorCounter.increment(); - }); - - retry.getEventPublisher().onIgnoredError(event -> { - ignoredErrorMeter.mark(); - ignoredErrorCounter.increment(); - }); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Constants.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Constants.java deleted file mode 100644 index 7035be3a8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Constants.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import io.dropwizard.util.DataSize; - -public class Constants { - public static final String METRICS_NAME = "textsecure"; - public static final int MAXIMUM_STICKER_SIZE_BYTES = (int) DataSize.kibibytes(300 + 1).toBytes(); // add 1 kiB for encryption overhead - public static final int MAXIMUM_STICKER_MANIFEST_SIZE_BYTES = (int) DataSize.kibibytes(10).toBytes(); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Conversions.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Conversions.java deleted file mode 100644 index 14f0008e4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Conversions.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.util; - -public class Conversions { - - public static byte intsToByteHighAndLow(int highValue, int lowValue) { - return (byte)((highValue << 4 | lowValue) & 0xFF); - } - - public static int highBitsToInt(byte value) { - return (value & 0xFF) >> 4; - } - - public static int lowBitsToInt(byte value) { - return (value & 0xF); - } - - public static int highBitsToMedium(int value) { - return (value >> 12); - } - - public static int lowBitsToMedium(int value) { - return (value & 0xFFF); - } - - public static byte[] shortToByteArray(int value) { - byte[] bytes = new byte[2]; - shortToByteArray(bytes, 0, value); - return bytes; - } - - public static int shortToByteArray(byte[] bytes, int offset, int value) { - bytes[offset+1] = (byte)value; - bytes[offset] = (byte)(value >> 8); - return 2; - } - - public static int shortToLittleEndianByteArray(byte[] bytes, int offset, int value) { - bytes[offset] = (byte)value; - bytes[offset+1] = (byte)(value >> 8); - return 2; - } - - public static byte[] mediumToByteArray(int value) { - byte[] bytes = new byte[3]; - mediumToByteArray(bytes, 0, value); - return bytes; - } - - public static int mediumToByteArray(byte[] bytes, int offset, int value) { - bytes[offset + 2] = (byte)value; - bytes[offset + 1] = (byte)(value >> 8); - bytes[offset] = (byte)(value >> 16); - return 3; - } - - public static byte[] intToByteArray(int value) { - byte[] bytes = new byte[4]; - intToByteArray(bytes, 0, value); - return bytes; - } - - public static int intToByteArray(byte[] bytes, int offset, int value) { - bytes[offset + 3] = (byte)value; - bytes[offset + 2] = (byte)(value >> 8); - bytes[offset + 1] = (byte)(value >> 16); - bytes[offset] = (byte)(value >> 24); - return 4; - } - - public static int intToLittleEndianByteArray(byte[] bytes, int offset, int value) { - bytes[offset] = (byte)value; - bytes[offset+1] = (byte)(value >> 8); - bytes[offset+2] = (byte)(value >> 16); - bytes[offset+3] = (byte)(value >> 24); - return 4; - } - - public static byte[] longToByteArray(long l) { - byte[] bytes = new byte[8]; - longToByteArray(bytes, 0, l); - return bytes; - } - - public static int longToByteArray(byte[] bytes, int offset, long value) { - bytes[offset + 7] = (byte)value; - bytes[offset + 6] = (byte)(value >> 8); - bytes[offset + 5] = (byte)(value >> 16); - bytes[offset + 4] = (byte)(value >> 24); - bytes[offset + 3] = (byte)(value >> 32); - bytes[offset + 2] = (byte)(value >> 40); - bytes[offset + 1] = (byte)(value >> 48); - bytes[offset] = (byte)(value >> 56); - return 8; - } - - public static int longTo4ByteArray(byte[] bytes, int offset, long value) { - bytes[offset + 3] = (byte)value; - bytes[offset + 2] = (byte)(value >> 8); - bytes[offset + 1] = (byte)(value >> 16); - bytes[offset + 0] = (byte)(value >> 24); - return 4; - } - - public static int byteArrayToShort(byte[] bytes) { - return byteArrayToShort(bytes, 0); - } - - public static int byteArrayToShort(byte[] bytes, int offset) { - return - (bytes[offset] & 0xff) << 8 | (bytes[offset + 1] & 0xff); - } - - // The SSL patented 3-byte Value. - public static int byteArrayToMedium(byte[] bytes, int offset) { - return - (bytes[offset] & 0xff) << 16 | - (bytes[offset + 1] & 0xff) << 8 | - (bytes[offset + 2] & 0xff); - } - - public static int byteArrayToInt(byte[] bytes) { - return byteArrayToInt(bytes, 0); - } - - public static int byteArrayToInt(byte[] bytes, int offset) { - return - (bytes[offset] & 0xff) << 24 | - (bytes[offset + 1] & 0xff) << 16 | - (bytes[offset + 2] & 0xff) << 8 | - (bytes[offset + 3] & 0xff); - } - - public static int byteArrayToIntLittleEndian(byte[] bytes, int offset) { - return - (bytes[offset + 3] & 0xff) << 24 | - (bytes[offset + 2] & 0xff) << 16 | - (bytes[offset + 1] & 0xff) << 8 | - (bytes[offset] & 0xff); - } - - public static long byteArrayToLong(byte[] bytes) { - return byteArrayToLong(bytes, 0); - } - - public static long byteArray4ToLong(byte[] bytes, int offset) { - return - ((bytes[offset + 0] & 0xffL) << 24) | - ((bytes[offset + 1] & 0xffL) << 16) | - ((bytes[offset + 2] & 0xffL) << 8) | - ((bytes[offset + 3] & 0xffL)); - } - - public static long byteArrayToLong(byte[] bytes, int offset) { - return - ((bytes[offset] & 0xffL) << 56) | - ((bytes[offset + 1] & 0xffL) << 48) | - ((bytes[offset + 2] & 0xffL) << 40) | - ((bytes[offset + 3] & 0xffL) << 32) | - ((bytes[offset + 4] & 0xffL) << 24) | - ((bytes[offset + 5] & 0xffL) << 16) | - ((bytes[offset + 6] & 0xffL) << 8) | - ((bytes[offset + 7] & 0xffL)); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/DestinationDeviceValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/DestinationDeviceValidator.java deleted file mode 100644 index 1c26c731e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/DestinationDeviceValidator.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.util; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException; -import org.whispersystems.textsecuregcm.controllers.StaleDevicesException; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; - -public class DestinationDeviceValidator { - - /** - * @see #validateRegistrationIds(Account, Stream, boolean) - */ - public static void validateRegistrationIds(final Account account, final Collection messages, - Function getDeviceId, Function getRegistrationId, boolean usePhoneNumberIdentity) - throws StaleDevicesException { - validateRegistrationIds(account, - messages.stream().map(m -> new Pair<>(getDeviceId.apply(m), getRegistrationId.apply(m))), - usePhoneNumberIdentity); - - } - - /** - * Validates that the given device ID/registration ID pairs exactly match the corresponding device ID/registration ID - * pairs in the given destination account. This method does not validate that all devices associated with the - * destination account are present in the given device ID/registration ID pairs. - * - * @param account the destination account against which to check the given device - * ID/registration ID pairs - * @param deviceIdAndRegistrationIdStream a stream of device ID and registration ID pairs - * @param usePhoneNumberIdentity if {@code true}, compare provided registration IDs against device - * registration IDs associated with the account's PNI (if available); compare - * against the ACI-associated registration ID otherwise - * @throws StaleDevicesException if the device ID/registration ID pairs contained an entry for which the destination - * account does not have a corresponding device or if the registration IDs do not match - */ - public static void validateRegistrationIds(final Account account, - final Stream> deviceIdAndRegistrationIdStream, - final boolean usePhoneNumberIdentity) throws StaleDevicesException { - - final List staleDevices = deviceIdAndRegistrationIdStream - .filter(deviceIdAndRegistrationId -> deviceIdAndRegistrationId.second() > 0) - .filter(deviceIdAndRegistrationId -> { - final long deviceId = deviceIdAndRegistrationId.first(); - final int registrationId = deviceIdAndRegistrationId.second(); - boolean registrationIdMatches = account.getDevice(deviceId) - .map(device -> registrationId == (usePhoneNumberIdentity - ? device.getPhoneNumberIdentityRegistrationId().orElse(device.getRegistrationId()) - : device.getRegistrationId())) - .orElse(false); - return !registrationIdMatches; - }) - .map(Pair::first) - .collect(Collectors.toList()); - - if (!staleDevices.isEmpty()) { - throw new StaleDevicesException(staleDevices); - } - } - - /** - * Validates that the given set of device IDs from a set of messages matches the set of device IDs associated with the - * given destination account in preparation for sending those messages to the destination account. In general, the set - * of device IDs must exactly match the set of active devices associated with the destination account. When sending a - * "sync," message, though, the authenticated account is sending messages from one of their devices to all other - * devices; in that case, callers must pass the ID of the sending device in the set of {@code excludedDeviceIds}. - * - * @param account the destination account against which to check the given set of device IDs - * @param messageDeviceIds the set of device IDs to check against the destination account - * @param excludedDeviceIds a set of device IDs that may be associated with the destination account, but must not be - * present in the given set of device IDs (i.e. the device that is sending a sync message) - * @throws MismatchedDevicesException if the given set of device IDs contains entries not currently associated with - * the destination account or is missing entries associated with the destination - * account - */ - public static void validateCompleteDeviceList(final Account account, - final Set messageDeviceIds, - final Set excludedDeviceIds) throws MismatchedDevicesException { - - final Set accountDeviceIds = account.getDevices().stream() - .filter(Device::isEnabled) - .map(Device::getId) - .filter(deviceId -> !excludedDeviceIds.contains(deviceId)) - .collect(Collectors.toSet()); - - final Set missingDeviceIds = new HashSet<>(accountDeviceIds); - missingDeviceIds.removeAll(messageDeviceIds); - - final Set extraDeviceIds = new HashSet<>(messageDeviceIds); - extraDeviceIds.removeAll(accountDeviceIds); - - if (!missingDeviceIds.isEmpty() || !extraDeviceIds.isEmpty()) { - throw new MismatchedDevicesException(new ArrayList<>(missingDeviceIds), new ArrayList<>(extraDeviceIds)); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java deleted file mode 100644 index 559baf3d2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/DynamoDbFromConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; - -public class DynamoDbFromConfig { - - public static DynamoDbClient client(DynamoDbClientConfiguration config, AwsCredentialsProvider credentialsProvider) { - return DynamoDbClient.builder() - .region(Region.of(config.getRegion())) - .credentialsProvider(credentialsProvider) - .overrideConfiguration(ClientOverrideConfiguration.builder() - .apiCallTimeout(config.getClientExecutionTimeout()) - .apiCallAttemptTimeout(config.getClientRequestTimeout()) - .build()) - .build(); - } - - public static DynamoDbAsyncClient asyncClient( - DynamoDbClientConfiguration config, - AwsCredentialsProvider credentialsProvider) { - return DynamoDbAsyncClient.builder() - .region(Region.of(config.getRegion())) - .credentialsProvider(credentialsProvider) - .overrideConfiguration(ClientOverrideConfiguration.builder() - .apiCallTimeout(config.getClientExecutionTimeout()) - .apiCallAttemptTimeout(config.getClientRequestTimeout()) - .build()) - .build(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java deleted file mode 100644 index 6dab03ea7..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.ElementType.PARAMETER; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; -import java.util.Objects; -import javax.validation.Constraint; -import javax.validation.ConstraintValidator; -import javax.validation.ConstraintValidatorContext; -import javax.validation.Payload; - -/** - * Constraint annotation that requires annotated entity - * to hold (or return) a string value that is a valid E164-normalized phone number. - */ -@Target({ FIELD, PARAMETER, METHOD }) -@Retention(RUNTIME) -@Constraint(validatedBy = E164.Validator.class) -@Documented -public @interface E164 { - - String message() default "value is not a valid E164 number"; - - Class[] groups() default { }; - - Class[] payload() default { }; - - class Validator implements ConstraintValidator { - - @Override - public boolean isValid(final String value, final ConstraintValidatorContext context) { - if (Objects.isNull(value)) { - return true; - } - if (!value.startsWith("+")) { - return false; - } - try { - Util.requireNormalizedNumber(value); - } catch (final ImpossiblePhoneNumberException | NonNormalizedPhoneNumberException e) { - return false; - } - return true; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java deleted file mode 100644 index d77a32ebd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static java.lang.annotation.ElementType.ANNOTATION_TYPE; -import static java.lang.annotation.ElementType.CONSTRUCTOR; -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.ElementType.PARAMETER; -import static java.lang.annotation.ElementType.TYPE_USE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; -import javax.validation.Constraint; -import javax.validation.Payload; - -@Target({ FIELD, METHOD, CONSTRUCTOR, PARAMETER, ANNOTATION_TYPE, TYPE_USE }) -@Retention(RUNTIME) -@Constraint(validatedBy = { - ExactlySizeValidatorForString.class, - ExactlySizeValidatorForArraysOfByte.class, - ExactlySizeValidatorForCollection.class, -}) -@Documented -public @interface ExactlySize { - - String message() default "{org.whispersystems.textsecuregcm.util.ExactlySize." + - "message}"; - - Class[] groups() default { }; - - Class[] payload() default { }; - - int[] value(); - - @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE }) - @Retention(RUNTIME) - @Documented - @interface List { - ExactlySize[] value(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java deleted file mode 100644 index a66cf939f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.util.Arrays; -import java.util.Set; -import java.util.stream.Collectors; -import javax.validation.ConstraintValidator; -import javax.validation.ConstraintValidatorContext; - -public abstract class ExactlySizeValidator implements ConstraintValidator { - - private Set permittedSizes; - - @Override - public void initialize(ExactlySize annotation) { - permittedSizes = Arrays.stream(annotation.value()).boxed().collect(Collectors.toSet()); - } - - @Override - public boolean isValid(T value, ConstraintValidatorContext context) { - return permittedSizes.contains(size(value)); - } - - protected abstract int size(T value); -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java deleted file mode 100644 index 358eaf65a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -public class ExactlySizeValidatorForArraysOfByte extends ExactlySizeValidator { - - @Override - protected int size(final byte[] value) { - return value == null ? 0 : value.length; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForCollection.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForCollection.java deleted file mode 100644 index df05bee24..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForCollection.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.util.Collection; - -public class ExactlySizeValidatorForCollection extends ExactlySizeValidator> { - - @Override - protected int size(final Collection value) { - return value == null ? 0 : value.size(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java deleted file mode 100644 index 9d77641cd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - - -public class ExactlySizeValidatorForString extends ExactlySizeValidator { - - @Override - protected int size(final String value) { - return value == null ? 0 : value.length(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExceptionUtils.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExceptionUtils.java deleted file mode 100644 index 28da692c2..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExceptionUtils.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import java.util.concurrent.CompletionException; - -public final class ExceptionUtils { - - private ExceptionUtils() { - // utility class - } - - /** - * Extracts the cause of a {@link CompletionException}. If the given {@code throwable} is a - * {@code CompletionException}, this method will recursively iterate through its causal chain until it finds the first - * cause that is not a {@code CompletionException}. If the last {@code CompletionException} in the causal chain has a - * {@code null} cause, then this method returns the last {@code CompletionException} in the chain. If the given - * {@code throwable} is not a {@code CompletionException}, then this method returns the original {@code throwable}. - * - * @param throwable the throwable to "unwrap" - * - * @return the first entity in the given {@code throwable}'s causal chain that is not a {@code CompletionException} - */ - public static Throwable unwrap(Throwable throwable) { - while (throwable instanceof CompletionException e && throwable.getCause() != null) { - throwable = e.getCause(); - } - return throwable; - } - - /** - * Wraps the given {@code throwable} in a {@link CompletionException} unless the given {@code throwable} is already - * a {@code CompletionException}, in which case this method returns the original throwable. - * - * @param throwable the throwable to wrap in a {@code CompletionException} - */ - public static CompletionException wrap(final Throwable throwable) { - return throwable instanceof CompletionException completionException - ? completionException - : new CompletionException(throwable); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExecutorUtils.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ExecutorUtils.java deleted file mode 100644 index c380be717..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ExecutorUtils.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.Executor; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -public class ExecutorUtils { - - public static Executor newFixedThreadBoundedQueueExecutor(int threadCount, int queueSize) { - ThreadPoolExecutor executor = new ThreadPoolExecutor(threadCount, threadCount, - Long.MAX_VALUE, TimeUnit.NANOSECONDS, - new ArrayBlockingQueue<>(queueSize), - new ThreadPoolExecutor.AbortPolicy()); - - executor.prestartAllCoreThreads(); - - return executor; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java deleted file mode 100644 index 85c3efc47..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static java.util.Objects.requireNonNull; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Optional; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import org.apache.commons.lang3.StringUtils; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; - -public final class HeaderUtils { - - public static final String X_SIGNAL_AGENT = "X-Signal-Agent"; - - public static final String X_SIGNAL_KEY = "X-Signal-Key"; - - public static final String TIMESTAMP_HEADER = "X-Signal-Timestamp"; - - private HeaderUtils() { - // utility class - } - - public static String basicAuthHeader(final ExternalServiceCredentials credentials) { - return basicAuthHeader(credentials.username(), credentials.password()); - } - - public static String basicAuthHeader(final String username, final String password) { - requireNonNull(username); - requireNonNull(password); - return "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); - } - - @Nonnull - public static String getTimestampHeader() { - return TIMESTAMP_HEADER + ":" + System.currentTimeMillis(); - } - - /** - * Returns the most recent proxy in a chain described by an {@code X-Forwarded-For} header. - * - * @param forwardedFor the value of an X-Forwarded-For header - * - * @return the IP address of the most recent proxy in the forwarding chain, or empty if none was found or - * {@code forwardedFor} was null - * - * @see X-Forwarded-For - HTTP | MDN - */ - @Nonnull - public static Optional getMostRecentProxy(@Nullable final String forwardedFor) { - return Optional.ofNullable(forwardedFor) - .map(ff -> { - final int idx = forwardedFor.lastIndexOf(',') + 1; - return idx < forwardedFor.length() - ? forwardedFor.substring(idx).trim() - : null; - }) - .filter(StringUtils::isNotBlank); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/HmacUtils.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/HmacUtils.java deleted file mode 100644 index 6469fe01b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/HmacUtils.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -public final class HmacUtils { - - private static final HexFormat HEX = HexFormat.of(); - - private static final String HMAC_SHA_256 = "HmacSHA256"; - - private static final ThreadLocal THREAD_LOCAL_HMAC_SHA_256 = ThreadLocal.withInitial(() -> { - try { - return Mac.getInstance(HMAC_SHA_256); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - }); - - public static byte[] hmac256(final byte[] key, final byte[] input) { - try { - final Mac mac = THREAD_LOCAL_HMAC_SHA_256.get(); - mac.init(new SecretKeySpec(key, HMAC_SHA_256)); - return mac.doFinal(input); - } catch (final InvalidKeyException e) { - throw new RuntimeException(e); - } - } - - public static byte[] hmac256(final byte[] key, final String input) { - return hmac256(key, input.getBytes(StandardCharsets.UTF_8)); - } - - public static String hmac256ToHexString(final byte[] key, final byte[] input) { - return HEX.formatHex(hmac256(key, input)); - } - - public static String hmac256ToHexString(final byte[] key, final String input) { - return hmac256ToHexString(key, input.getBytes(StandardCharsets.UTF_8)); - } - - public static byte[] hmac256Truncated(final byte[] key, final byte[] input, final int length) { - return Util.truncate(hmac256(key, input), length); - } - - public static byte[] hmac256Truncated(final byte[] key, final String input, final int length) { - return hmac256Truncated(key, input.getBytes(StandardCharsets.UTF_8), length); - } - - public static String hmac256TruncatedToHexString(final byte[] key, final byte[] input, final int length) { - return HEX.formatHex(Util.truncate(hmac256(key, input), length)); - } - - public static String hmac256TruncatedToHexString(final byte[] key, final String input, final int length) { - return hmac256TruncatedToHexString(key, input.getBytes(StandardCharsets.UTF_8), length); - } - - public static boolean hmacHexStringsEqual(final String expectedAsHexString, final String actualAsHexString) { - try { - final byte[] aBytes = HEX.parseHex(expectedAsHexString); - final byte[] bBytes = HEX.parseHex(actualAsHexString); - return MessageDigest.isEqual(aBytes, bBytes); - } catch (final IllegalArgumentException e) { - return false; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/HostnameUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/HostnameUtil.java deleted file mode 100644 index ad8c1a8f9..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/HostnameUtil.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Locale; - -public class HostnameUtil { - - private static final Logger log = LoggerFactory.getLogger(HostnameUtil.class); - - public static String getLocalHostname() { - try { - return InetAddress.getLocalHost().getHostName().toLowerCase(Locale.US); - } catch (final UnknownHostException e) { - log.warn("Failed to get hostname", e); - return "unknown"; - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/HttpUtils.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/HttpUtils.java deleted file mode 100644 index 4f5169fd5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/HttpUtils.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -public final class HttpUtils { - - private HttpUtils() { - // utility class - } - - public static boolean isSuccessfulResponse(final int statusCode) { - return statusCode >= 200 && statusCode < 300; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ImpossiblePhoneNumberException.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ImpossiblePhoneNumberException.java deleted file mode 100644 index 1e6e0d73e..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ImpossiblePhoneNumberException.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -public class ImpossiblePhoneNumberException extends Exception { - - public ImpossiblePhoneNumberException() { - super(); - } - - public ImpossiblePhoneNumberException(final Throwable cause) { - super(cause); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/IterablePair.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/IterablePair.java deleted file mode 100644 index 7f94099d5..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/IterablePair.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.util; - -import java.util.Iterator; -import java.util.List; - -public class IterablePair implements Iterable> { - private final List first; - private final List second; - - public IterablePair(List first, List second) { - this.first = first; - this.second = second; - } - - @Override - public Iterator> iterator(){ - return new ParallelIterator<>( first.iterator(), second.iterator() ); - } - - public static class ParallelIterator implements Iterator> { - - private final Iterator it1; - private final Iterator it2; - - public ParallelIterator(Iterator it1, Iterator it2) { - this.it1 = it1; this.it2 = it2; - } - - @Override - public boolean hasNext() { return it1.hasNext() && it2.hasNext(); } - - @Override - public Pair next() { - return new Pair<>(it1.next(), it2.next()); - } - - @Override - public void remove(){ - it1.remove(); - it2.remove(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/NonNormalizedPhoneNumberException.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/NonNormalizedPhoneNumberException.java deleted file mode 100644 index 4ce4d11b3..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/NonNormalizedPhoneNumberException.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -public class NonNormalizedPhoneNumberException extends Exception { - - private final String originalNumber; - private final String normalizedNumber; - - public NonNormalizedPhoneNumberException(final String originalNumber, final String normalizedNumber) { - this.originalNumber = originalNumber; - this.normalizedNumber = normalizedNumber; - } - - public String getOriginalNumber() { - return originalNumber; - } - - public String getNormalizedNumber() { - return normalizedNumber; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java deleted file mode 100644 index 4e3e49038..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import java.util.Optional; -import java.util.function.BiFunction; - -public class Optionals { - - private Optionals() {} - - /** - * Apply a function to two optional arguments, returning empty if either argument is empty - * - * @param optionalT Optional of type T - * @param optionalU Optional of type U - * @param fun Function of T and U that returns R - * @return The function applied to the values of optionalT and optionalU, or empty - */ - public static Optional zipWith(Optional optionalT, Optional optionalU, BiFunction fun) { - return optionalT.flatMap(t -> optionalU.map(u -> fun.apply(t, u))); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Pair.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Pair.java deleted file mode 100644 index 014a5e906..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Pair.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.util; - -import static com.google.common.base.Objects.equal; - -public class Pair { - private final T1 v1; - private final T2 v2; - - public Pair(T1 v1, T2 v2) { - this.v1 = v1; - this.v2 = v2; - } - - public T1 first() { - return v1; - } - - public T2 second() { - return v2; - } - - public boolean equals(Object o) { - return o instanceof Pair && - equal(((Pair) o).first(), first()) && - equal(((Pair) o).second(), second()); - } - - public int hashCode() { - return first().hashCode() ^ second().hashCode(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/RedisClusterUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/RedisClusterUtil.java deleted file mode 100644 index 188e1e4f4..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/RedisClusterUtil.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import io.lettuce.core.cluster.SlotHash; - -public class RedisClusterUtil { - - private static final String[] HASHES_BY_SLOT = new String[SlotHash.SLOT_COUNT]; - - static { - int slotsCovered = 0; - int i = 0; - - while (slotsCovered < HASHES_BY_SLOT.length) { - final String hash = Integer.toString(i++, 36); - final int slot = SlotHash.getSlot(hash); - - if (HASHES_BY_SLOT[slot] == null) { - HASHES_BY_SLOT[slot] = hash; - slotsCovered += 1; - } - } - } - - /** - * Returns a Redis hash tag that maps to the given cluster slot. - * - * @param slot the Redis cluster slot for which to retrieve a hash tag - * - * @return a Redis hash tag that maps to the given cluster slot - * - * @see Redis Cluster Specification - Keys hash tags - */ - public static String getMinimalHashTag(final int slot) { - return HASHES_BY_SLOT[slot]; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/SystemMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/SystemMapper.java deleted file mode 100644 index 7c3e6b667..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/SystemMapper.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import javax.annotation.Nonnull; - -public class SystemMapper { - - private static final ObjectMapper MAPPER = build(); - - - @Nonnull - public static ObjectMapper getMapper() { - return MAPPER; - } - - @Nonnull - private static ObjectMapper build() { - final ObjectMapper mapper = new ObjectMapper(); - mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); - mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); - mapper.registerModule(new JavaTimeModule()); - mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - return mapper; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ThreadDumpUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ThreadDumpUtil.java deleted file mode 100644 index 0fdf3bdab..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ThreadDumpUtil.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.io.IOException; -import java.io.PrintWriter; -import java.lang.management.ManagementFactory; -import java.lang.management.ThreadInfo; - -public class ThreadDumpUtil { - - private static final Logger log = LoggerFactory.getLogger(ThreadDumpUtil.class); - - public static void writeThreadDump() { - try { - try (final PrintWriter out = new PrintWriter(File.createTempFile("thread_dump_", ".txt"))) { - for (ThreadInfo info : ManagementFactory.getThreadMXBean().dumpAllThreads(true, true)) { - out.print(info); - } - } - } catch (final IOException e) { - log.warn("Failed to write thread dump", e); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/UUIDUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/UUIDUtil.java deleted file mode 100644 index 3f1d91d7c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/UUIDUtil.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.nio.BufferUnderflowException; -import java.nio.ByteBuffer; -import java.util.Optional; -import java.util.UUID; - -public final class UUIDUtil { - - private UUIDUtil() { - // utility class - } - - public static byte[] toBytes(final UUID uuid) { - return toByteBuffer(uuid).array(); - } - - public static ByteBuffer toByteBuffer(final UUID uuid) { - final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]); - byteBuffer.putLong(uuid.getMostSignificantBits()); - byteBuffer.putLong(uuid.getLeastSignificantBits()); - return byteBuffer.flip(); - } - - public static UUID fromBytes(final byte[] bytes) { - return fromByteBuffer(ByteBuffer.wrap(bytes)); - } - - public static UUID fromByteBuffer(final ByteBuffer byteBuffer) { - try { - final long mostSigBits = byteBuffer.getLong(); - final long leastSigBits = byteBuffer.getLong(); - if (byteBuffer.hasRemaining()) { - throw new IllegalArgumentException("unexpected byte array length; was greater than 16"); - } - return new UUID(mostSigBits, leastSigBits); - } catch (BufferUnderflowException e) { - throw new IllegalArgumentException("unexpected byte array length; was less than 16"); - } - } - - public static Optional fromStringSafe(final String uuidString) { - try { - return Optional.of(UUID.fromString(uuidString)); - } catch (final IllegalArgumentException e) { - return Optional.empty(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/UsernameHashZkProofVerifier.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/UsernameHashZkProofVerifier.java deleted file mode 100644 index 4570060be..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/UsernameHashZkProofVerifier.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import org.signal.libsignal.usernames.BaseUsernameException; -import org.signal.libsignal.usernames.Username; - -public class UsernameHashZkProofVerifier { - public void verifyProof(byte[] proof, byte[] hash) throws BaseUsernameException { - Username.verifyProof(proof, hash); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java deleted file mode 100644 index abe09b481..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.util; - -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; -import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; -import java.time.Clock; -import java.time.Duration; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Locale; -import java.util.Locale.LanguageRange; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import javax.annotation.Nonnull; -import org.apache.commons.lang3.StringUtils; - -public class Util { - - private static final Pattern COUNTRY_CODE_PATTERN = Pattern.compile("^\\+([17]|2[07]|3[0123469]|4[013456789]|5[12345678]|6[0123456]|8[1246]|9[0123458]|\\d{3})"); - - private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance(); - - public static final Runnable NOOP = () -> {}; - - /** - * Checks that the given number is a valid, E164-normalized phone number. - * - * @param number the number to check - * - * @throws ImpossiblePhoneNumberException if the given number is not a valid phone number at all - * @throws NonNormalizedPhoneNumberException if the given number is a valid phone number, but isn't E164-normalized - */ - public static void requireNormalizedNumber(final String number) throws ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException { - if (!PHONE_NUMBER_UTIL.isPossibleNumber(number, null)) { - throw new ImpossiblePhoneNumberException(); - } - - try { - final PhoneNumber inputNumber = PHONE_NUMBER_UTIL.parse(number, null); - - // For normalization, we want to format from a version parsed with the country code removed. - // This handles some cases of "possible", but non-normalized input numbers with a doubled country code, that is - // with the format "+{country code} {country code} {national number}" - final int countryCode = inputNumber.getCountryCode(); - final String region = PHONE_NUMBER_UTIL.getRegionCodeForCountryCode(countryCode); - - final PhoneNumber normalizedNumber = switch (region) { - // the country code has no associated region. Be lenient (and simple) and accept the input number - case "ZZ", "001" -> inputNumber; - default -> { - final String maybeLeadingZero = - inputNumber.hasItalianLeadingZero() && inputNumber.isItalianLeadingZero() ? "0" : ""; - yield PHONE_NUMBER_UTIL.parse( - maybeLeadingZero + inputNumber.getNationalNumber(), region); - } - }; - - final String normalizedE164 = PHONE_NUMBER_UTIL.format(normalizedNumber, - PhoneNumberFormat.E164); - - if (!number.equals(normalizedE164)) { - throw new NonNormalizedPhoneNumberException(number, normalizedE164); - } - } catch (final NumberParseException e) { - throw new ImpossiblePhoneNumberException(e); - } - } - - public static String getCountryCode(String number) { - Matcher matcher = COUNTRY_CODE_PATTERN.matcher(number); - - if (matcher.find()) return matcher.group(1); - else return "0"; - } - - public static String getRegion(final String number) { - try { - final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null); - return StringUtils.defaultIfBlank(PHONE_NUMBER_UTIL.getRegionCodeForNumber(phoneNumber), "ZZ"); - } catch (final NumberParseException e) { - return "ZZ"; - } - } - - public static String getNumberPrefix(String number) { - String countryCode = getCountryCode(number); - int remaining = number.length() - (1 + countryCode.length()); - int prefixLength = Math.min(4, remaining); - - return number.substring(0, 1 + countryCode.length() + prefixLength); - } - - public static boolean isEmpty(String param) { - return param == null || param.length() == 0; - } - - public static boolean nonEmpty(String param) { - return !isEmpty(param); - } - - public static byte[] truncate(byte[] element, int length) { - byte[] result = new byte[length]; - System.arraycopy(element, 0, result, 0, result.length); - - return result; - } - - public static byte[][] split(byte[] input, int firstLength, int secondLength) { - byte[][] parts = new byte[2][]; - - parts[0] = new byte[firstLength]; - System.arraycopy(input, 0, parts[0], 0, firstLength); - - parts[1] = new byte[secondLength]; - System.arraycopy(input, firstLength, parts[1], 0, secondLength); - - return parts; - } - - public static byte[][] split(byte[] input, int firstLength, int secondLength, int thirdLength, int fourthLength) { - byte[][] parts = new byte[4][]; - - parts[0] = new byte[firstLength]; - System.arraycopy(input, 0, parts[0], 0, firstLength); - - parts[1] = new byte[secondLength]; - System.arraycopy(input, firstLength, parts[1], 0, secondLength); - - parts[2] = new byte[thirdLength]; - System.arraycopy(input, firstLength + secondLength, parts[2], 0, thirdLength); - - parts[3] = new byte[fourthLength]; - System.arraycopy(input, firstLength + secondLength + thirdLength, parts[3], 0, fourthLength); - - return parts; - } - - public static final long DAY_IN_MILLIS = 86400000L; - public static final long WEEK_IN_MILLIS = DAY_IN_MILLIS * 7; - - public static int currentDaysSinceEpoch(@Nonnull Clock clock) { - return Math.toIntExact(clock.millis() / DAY_IN_MILLIS); - } - - public static void sleep(long i) { - try { - Thread.sleep(i); - } catch (InterruptedException ie) {} - } - - public static void wait(Object object) { - try { - object.wait(); - } catch (InterruptedException e) { - throw new AssertionError(e); - } - } - - public static void wait(Object object, long timeoutMs) { - try { - object.wait(timeoutMs); - } catch (InterruptedException e) { - throw new AssertionError(e); - } - } - - public static int hashCode(Object... objects) { - return Arrays.hashCode(objects); - } - - public static long todayInMillis() { - return todayInMillis(Clock.systemUTC()); - } - - public static long todayInMillis(Clock clock) { - return TimeUnit.DAYS.toMillis(TimeUnit.MILLISECONDS.toDays(clock.millis())); - } - - public static long todayInMillisGivenOffsetFromNow(Clock clock, Duration offset) { - final long ms = offset.toMillis() + clock.millis(); - return TimeUnit.DAYS.toMillis(TimeUnit.MILLISECONDS.toDays(ms)); - } - - public static Optional findBestLocale(List priorityList, Collection supportedLocales) { - return Optional.ofNullable(Locale.lookupTag(priorityList, supportedLocales)); - } - - /** - * Map ints to non-negative ints. - *
- * Unlike Math.abs this method handles Integer.MIN_VALUE correctly. - * - * @param n any int value - * @return an int value guaranteed to be non-negative - */ - public static int ensureNonNegativeInt(int n) { - return n == Integer.MIN_VALUE ? 0 : Math.abs(n); - } - - /** - * Map longs to non-negative longs. - *
- * Unlike Math.abs this method handles Long.MIN_VALUE correctly. - * - * @param n any long value - * @return a long value guaranteed to be non-negative - */ - public static long ensureNonNegativeLong(long n) { - return n == Long.MIN_VALUE ? 0 : Math.abs(n); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/VerificationCode.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/VerificationCode.java deleted file mode 100644 index 888752e19..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/VerificationCode.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.util; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.annotations.VisibleForTesting; - -public class VerificationCode { - - @JsonProperty - private String verificationCode; - @JsonIgnore - private String verificationCodeDisplay; - @JsonIgnore - private String verificationCodeSpeech; - - @VisibleForTesting VerificationCode() {} - - public VerificationCode(int verificationCode) { - this(verificationCode + ""); - } - - public VerificationCode(String verificationCode) { - this.verificationCode = verificationCode; - this.verificationCodeDisplay = this.verificationCode.substring(0, 3) + "-" + this.verificationCode.substring(3, 6); - this.verificationCodeSpeech = delimit(verificationCode + ""); - } - - public String getVerificationCode() { - return verificationCode; - } - - public String getVerificationCodeDisplay() { - return verificationCodeDisplay; - } - - public String getVerificationCodeSpeech() { - return verificationCodeSpeech; - } - - private String delimit(String code) { - String delimited = ""; - - for (int i=0;i the type of the objects to select from - */ -public class WeightedRandomSelect { - - List> weightedItems; - long totalWeight; - - public WeightedRandomSelect(List> weightedItems) throws IllegalArgumentException { - this.weightedItems = weightedItems; - this.totalWeight = weightedItems.stream().mapToLong(Pair::second).sum(); - - weightedItems.stream().map(Pair::second).filter(w -> w < 0).findFirst().ifPresent(invalid -> { - throw new IllegalArgumentException("Illegal selection weight " + invalid); - }); - - if (weightedItems.isEmpty() || totalWeight == 0) { - throw new IllegalArgumentException("Cannot create an empty weighted random selector"); - } - } - - public T select() { - if (weightedItems.size() == 1) { - return weightedItems.get(0).first(); - } - long select = ThreadLocalRandom.current().nextLong(0, totalWeight); - long current = 0; - for (Pair item : weightedItems) { - /* - Accumulate weights for each item and select the first item whose - cumulative weight exceeds the selected value. nextLong() is exclusive, - so by the last item we're guaranteed to find a value as the - last item's weight is one more than the maximum value of select. - */ - current += item.second(); - if (current > select) { - return item.first(); - } - } - throw new IllegalStateException("totalWeight " + totalWeight + " exceeds item weights"); - } - - public static T select(List> weightedItems) { - return new WeightedRandomSelect(weightedItems).select(); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapper.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapper.java deleted file mode 100644 index ca4757022..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapper.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.net.HttpHeaders; -import io.dropwizard.jersey.errors.LoggingExceptionMapper; -import javax.inject.Provider; -import javax.ws.rs.core.Context; -import org.glassfish.jersey.server.ContainerRequest; -import org.slf4j.Logger; -import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; - -public class LoggingUnhandledExceptionMapper extends LoggingExceptionMapper { - - @Context - private Provider request; - - public LoggingUnhandledExceptionMapper() { - super(); - } - - @VisibleForTesting - LoggingUnhandledExceptionMapper(final Logger logger) { - super(logger); - } - - @Override - protected String formatLogMessage(final long id, final Throwable exception) { - String requestMethod = "unknown method"; - String userAgent = "missing"; - String requestPath = "/{unknown path}"; - try { - // request shouldn’t be `null`, but it is technically possible - requestMethod = request.get().getMethod(); - requestPath = UriInfoUtil.getPathTemplate(request.get().getUriInfo()); - userAgent = request.get().getHeaderString(HttpHeaders.USER_AGENT); - - // streamline the user-agent if it is recognized - final UserAgent ua = UserAgentUtil.parseUserAgentString(userAgent); - userAgent = String.format("%s %s", ua.getPlatform(), ua.getVersion()); - } catch (final UnrecognizedUserAgentException ignored) { - - } catch (final Exception e) { - logger.warn("Unexpected exception getting request details", e); - } - - return String.format("%s at %s %s (%s)", - super.formatLogMessage(id, exception), - requestMethod, - requestPath, - userAgent) ; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilter.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilter.java deleted file mode 100644 index 640f4d47a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilter.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import ch.qos.logback.core.filter.Filter; -import ch.qos.logback.core.spi.FilterReply; - -class RequestLogEnabledFilter extends Filter { - - private volatile boolean requestLoggingEnabled = false; - - @Override - public FilterReply decide(final E event) { - return requestLoggingEnabled ? FilterReply.NEUTRAL : FilterReply.DENY; - } - - public void setRequestLoggingEnabled(final boolean requestLoggingEnabled) { - this.requestLoggingEnabled = requestLoggingEnabled; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilterFactory.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilterFactory.java deleted file mode 100644 index 7de341827..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilterFactory.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import ch.qos.logback.access.spi.IAccessEvent; -import ch.qos.logback.core.filter.Filter; -import com.fasterxml.jackson.annotation.JsonTypeName; -import io.dropwizard.logging.filter.FilterFactory; - -@JsonTypeName("requestLogEnabled") -class RequestLogEnabledFilterFactory implements FilterFactory { - - @Override - public Filter build() { - return RequestLogManager.getHttpRequestLogFilter(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogManager.java deleted file mode 100644 index 1fc38fa49..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogManager.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import ch.qos.logback.access.spi.IAccessEvent; -import ch.qos.logback.core.filter.Filter; - -public class RequestLogManager { - private static final RequestLogEnabledFilter HTTP_REQUEST_LOG_FILTER = new RequestLogEnabledFilter<>(); - - static Filter getHttpRequestLogFilter() { - return HTTP_REQUEST_LOG_FILTER; - } - - public static void setRequestLoggingEnabled(final boolean enabled) { - HTTP_REQUEST_LOG_FILTER.setRequestLoggingEnabled(enabled); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UncaughtExceptionHandler.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UncaughtExceptionHandler.java deleted file mode 100644 index 35409fb05..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UncaughtExceptionHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class UncaughtExceptionHandler { - - private static final Logger logger = LoggerFactory.getLogger(UncaughtExceptionHandler.class); - - public static void register() { - @Nullable final Thread.UncaughtExceptionHandler current = Thread.getDefaultUncaughtExceptionHandler(); - - if (current != null) { - logger.warn("Uncaught exception handler already exists: {}", current); - return; - } - - Thread.setDefaultUncaughtExceptionHandler((t, e) -> logger.error("Uncaught exception on thread {}", t, e)); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtil.java deleted file mode 100644 index bd94d2c18..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtil.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import org.glassfish.jersey.server.ExtendedUriInfo; - -public class UriInfoUtil { - - public static String getPathTemplate(final ExtendedUriInfo uriInfo) { - final StringBuilder pathBuilder = new StringBuilder(); - - for (int i = uriInfo.getMatchedTemplates().size() - 1; i >= 0; i--) { - pathBuilder.append(uriInfo.getMatchedTemplates().get(i).getTemplate()); - } - - return pathBuilder.toString(); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/ClientPlatform.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/ClientPlatform.java deleted file mode 100644 index e9c05e970..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/ClientPlatform.java +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.ua; - -public enum ClientPlatform { - ANDROID, - DESKTOP, - IOS; -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UnrecognizedUserAgentException.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UnrecognizedUserAgentException.java deleted file mode 100644 index 149ed5406..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UnrecognizedUserAgentException.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.ua; - -public class UnrecognizedUserAgentException extends Exception { - - public UnrecognizedUserAgentException() { - } - - public UnrecognizedUserAgentException(final String message) { - super(message); - } - - public UnrecognizedUserAgentException(final Throwable cause) { - super(cause); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java deleted file mode 100644 index 3f3714662..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.ua; - -import com.vdurmont.semver4j.Semver; - -import java.util.Objects; -import java.util.Optional; - -public class UserAgent { - - private final ClientPlatform platform; - private final Semver version; - private final String additionalSpecifiers; - - public UserAgent(final ClientPlatform platform, final Semver version) { - this(platform, version, null); - } - - public UserAgent(final ClientPlatform platform, final Semver version, final String additionalSpecifiers) { - this.platform = platform; - this.version = version; - this.additionalSpecifiers = additionalSpecifiers; - } - - public ClientPlatform getPlatform() { - return platform; - } - - public Semver getVersion() { - return version; - } - - public Optional getAdditionalSpecifiers() { - return Optional.ofNullable(additionalSpecifiers); - } - - @Override - public boolean equals(final Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - final UserAgent userAgent = (UserAgent)o; - return platform == userAgent.platform && - version.equals(userAgent.version) && - Objects.equals(additionalSpecifiers, userAgent.additionalSpecifiers); - } - - @Override - public int hashCode() { - return Objects.hash(platform, version, additionalSpecifiers); - } - - @Override - public String toString() { - return "UserAgent{" + - "platform=" + platform + - ", version=" + version + - ", additionalSpecifiers='" + additionalSpecifiers + '\'' + - '}'; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java b/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java deleted file mode 100644 index a0e718942..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.ua; - -import com.google.common.annotations.VisibleForTesting; -import com.vdurmont.semver4j.Semver; -import org.apache.commons.lang3.StringUtils; - -import java.util.EnumMap; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class UserAgentUtil { - - private static final Pattern STANDARD_UA_PATTERN = Pattern.compile("^Signal-(Android|Desktop|iOS)/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE); - - private static final Map LEGACY_PATTERNS_BY_PLATFORM = new EnumMap<>(ClientPlatform.class); - - static { - LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.ANDROID, Pattern.compile("^Signal-Android ([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE)); - LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.DESKTOP, Pattern.compile("^Signal Desktop (.+)$", Pattern.CASE_INSENSITIVE)); - LEGACY_PATTERNS_BY_PLATFORM.put(ClientPlatform.IOS, Pattern.compile("^Signal/([^ ]+)( (.+))?$", Pattern.CASE_INSENSITIVE)); - } - - public static UserAgent parseUserAgentString(final String userAgentString) throws UnrecognizedUserAgentException { - if (StringUtils.isBlank(userAgentString)) { - throw new UnrecognizedUserAgentException("User-Agent string is blank"); - } - - try { - final UserAgent standardUserAgent = parseStandardUserAgentString(userAgentString); - - if (standardUserAgent != null) { - return standardUserAgent; - } - - final UserAgent legacyUserAgent = parseLegacyUserAgentString(userAgentString); - - if (legacyUserAgent != null) { - return legacyUserAgent; - } - } catch (final Exception e) { - throw new UnrecognizedUserAgentException(e); - } - - throw new UnrecognizedUserAgentException(); - } - - @VisibleForTesting - static UserAgent parseStandardUserAgentString(final String userAgentString) { - final Matcher matcher = STANDARD_UA_PATTERN.matcher(userAgentString); - - if (matcher.matches()) { - return new UserAgent(ClientPlatform.valueOf(matcher.group(1).toUpperCase()), new Semver(matcher.group(2)), StringUtils.stripToNull(matcher.group(4))); - } - - return null; - } - - @VisibleForTesting - static UserAgent parseLegacyUserAgentString(final String userAgentString) { - for (final Map.Entry entry : LEGACY_PATTERNS_BY_PLATFORM.entrySet()) { - final ClientPlatform platform = entry.getKey(); - final Pattern pattern = entry.getValue(); - final Matcher matcher = pattern.matcher(userAgentString); - - if (matcher.matches()) { - final UserAgent userAgent; - - if (matcher.groupCount() > 1) { - userAgent = new UserAgent(platform, new Semver(matcher.group(1)), StringUtils.stripToNull(matcher.group(matcher.groupCount()))); - } else { - userAgent = new UserAgent(platform, new Semver(matcher.group(1))); - } - - return userAgent; - } - } - - return null; - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java deleted file mode 100644 index e982c70ab..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Counter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.codahale.metrics.Timer; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.redis.RedisOperation; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.websocket.session.WebSocketSessionContext; -import org.whispersystems.websocket.setup.WebSocketConnectListener; - -public class AuthenticatedConnectListener implements WebSocketConnectListener { - - private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private static final Timer durationTimer = metricRegistry.timer( - name(WebSocketConnection.class, "connected_duration")); - private static final Timer unauthenticatedDurationTimer = metricRegistry.timer( - name(WebSocketConnection.class, "unauthenticated_connection_duration")); - private static final Counter openWebsocketCounter = metricRegistry.counter( - name(WebSocketConnection.class, "open_websockets")); - - private static final String OPEN_WEBSOCKET_COUNTER_NAME = MetricsUtil.name(WebSocketConnection.class, - "openWebsockets"); - - private static final long RENEW_PRESENCE_INTERVAL_MINUTES = 5; - - private static final Logger log = LoggerFactory.getLogger(AuthenticatedConnectListener.class); - - private final ReceiptSender receiptSender; - private final MessagesManager messagesManager; - private final PushNotificationManager pushNotificationManager; - private final ClientPresenceManager clientPresenceManager; - private final ScheduledExecutorService scheduledExecutorService; - - public AuthenticatedConnectListener(ReceiptSender receiptSender, - MessagesManager messagesManager, - PushNotificationManager pushNotificationManager, - ClientPresenceManager clientPresenceManager, - ScheduledExecutorService scheduledExecutorService) { - this.receiptSender = receiptSender; - this.messagesManager = messagesManager; - this.pushNotificationManager = pushNotificationManager; - this.clientPresenceManager = clientPresenceManager; - this.scheduledExecutorService = scheduledExecutorService; - } - - @Override - public void onWebSocketConnect(WebSocketSessionContext context) { - if (context.getAuthenticated() != null) { - final AuthenticatedAccount auth = context.getAuthenticated(AuthenticatedAccount.class); - final Device device = auth.getAuthenticatedDevice(); - final Timer.Context timer = durationTimer.time(); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, - messagesManager, auth, device, - context.getClient(), - scheduledExecutorService); - - openWebsocketCounter.inc(); - - pushNotificationManager.handleMessagesRetrieved(auth.getAccount(), device, context.getClient().getUserAgent()); - - final AtomicReference> renewPresenceFutureReference = new AtomicReference<>(); - - context.addListener((closingContext, statusCode, reason) -> { - openWebsocketCounter.dec(); - - timer.stop(); - - final ScheduledFuture renewPresenceFuture = renewPresenceFutureReference.get(); - - if (renewPresenceFuture != null) { - renewPresenceFuture.cancel(false); - } - - connection.stop(); - - RedisOperation.unchecked( - () -> clientPresenceManager.clearPresence(auth.getAccount().getUuid(), device.getId())); - RedisOperation.unchecked(() -> { - messagesManager.removeMessageAvailabilityListener(connection); - - if (messagesManager.hasCachedMessages(auth.getAccount().getUuid(), device.getId())) { - try { - pushNotificationManager.sendNewMessageNotification(auth.getAccount(), device.getId(), true); - } catch (NotPushRegisteredException ignored) { - } - } - }); - }); - - try { - connection.start(); - clientPresenceManager.setPresent(auth.getAccount().getUuid(), device.getId(), connection); - messagesManager.addMessageAvailabilityListener(auth.getAccount().getUuid(), device.getId(), connection); - - renewPresenceFutureReference.set(scheduledExecutorService.scheduleAtFixedRate(() -> RedisOperation.unchecked(() -> - clientPresenceManager.renewPresence(auth.getAccount().getUuid(), device.getId())), - RENEW_PRESENCE_INTERVAL_MINUTES, - RENEW_PRESENCE_INTERVAL_MINUTES, - TimeUnit.MINUTES)); - } catch (final Exception e) { - log.warn("Failed to initialize websocket", e); - context.getClient().close(1011, "Unexpected error initializing connection"); - } - } else { - final Timer.Context timer = unauthenticatedDurationTimer.time(); - context.addListener((context1, statusCode, reason) -> timer.stop()); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/InvalidWebsocketAddressException.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/InvalidWebsocketAddressException.java deleted file mode 100644 index 7be8e3f6d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/InvalidWebsocketAddressException.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -public class InvalidWebsocketAddressException extends Exception { - public InvalidWebsocketAddressException(String serialized) { - super(serialized); - } - - public InvalidWebsocketAddressException(Exception e) { - super(e); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningAddress.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningAddress.java deleted file mode 100644 index 38008bf76..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningAddress.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import java.security.SecureRandom; -import java.util.Base64; - -public class ProvisioningAddress extends WebsocketAddress { - - public ProvisioningAddress(String address, int id) { - super(address, id); - } - - public ProvisioningAddress(String serialized) throws InvalidWebsocketAddressException { - super(serialized); - } - - public String getAddress() { - return getNumber(); - } - - public static ProvisioningAddress generate() { - byte[] random = new byte[16]; - new SecureRandom().nextBytes(random); - - return new ProvisioningAddress(Base64.getUrlEncoder().withoutPadding().encodeToString(random), 0); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java deleted file mode 100644 index e1249da43..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import org.whispersystems.textsecuregcm.storage.PubSubManager; -import org.whispersystems.websocket.session.WebSocketSessionContext; -import org.whispersystems.websocket.setup.WebSocketConnectListener; - -public class ProvisioningConnectListener implements WebSocketConnectListener { - - private final PubSubManager pubSubManager; - - public ProvisioningConnectListener(PubSubManager pubSubManager) { - this.pubSubManager = pubSubManager; - } - - @Override - public void onWebSocketConnect(WebSocketSessionContext context) { - final ProvisioningConnection connection = new ProvisioningConnection(context.getClient()); - final ProvisioningAddress provisioningAddress = ProvisioningAddress.generate(); - - pubSubManager.subscribe(provisioningAddress, connection); - - context.addListener(new WebSocketSessionContext.WebSocketEventListener() { - @Override - public void onWebSocketClose(WebSocketSessionContext context, int statusCode, String reason) { - pubSubManager.unsubscribe(provisioningAddress, connection); - } - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnection.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnection.java deleted file mode 100644 index c8521a26b..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnection.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import com.google.protobuf.InvalidProtocolBufferException; -import java.util.Collections; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.dispatch.DispatchChannel; -import org.whispersystems.textsecuregcm.entities.MessageProtos.ProvisioningUuid; -import org.whispersystems.textsecuregcm.storage.PubSubProtos.PubSubMessage; -import org.whispersystems.textsecuregcm.util.HeaderUtils; -import org.whispersystems.websocket.WebSocketClient; - -public class ProvisioningConnection implements DispatchChannel { - - private final Logger logger = LoggerFactory.getLogger(ProvisioningConnection.class); - - private final WebSocketClient client; - - public ProvisioningConnection(WebSocketClient client) { - this.client = client; - } - - @Override - public void onDispatchMessage(String channel, byte[] message) { - try { - PubSubMessage outgoingMessage = PubSubMessage.parseFrom(message); - - if (outgoingMessage.getType() == PubSubMessage.Type.DELIVER) { - Optional body = Optional.of(outgoingMessage.getContent().toByteArray()); - - client.sendRequest("PUT", "/v1/message", Collections.singletonList(HeaderUtils.getTimestampHeader()), body) - .thenAccept(response -> client.close(1001, "All you get.")) - .exceptionally(throwable -> { - client.close(1001, "That's all!"); - return null; - }); - } - } catch (InvalidProtocolBufferException e) { - logger.warn("Protobuf Error: ", e); - } - } - - @Override - public void onDispatchSubscribed(String channel) { - try { - ProvisioningAddress address = new ProvisioningAddress(channel); - this.client.sendRequest("PUT", "/v1/address", Collections.singletonList(HeaderUtils.getTimestampHeader()), - Optional.of(ProvisioningUuid.newBuilder() - .setUuid(address.getAddress()) - .build() - .toByteArray())); - - } catch (InvalidWebsocketAddressException e) { - logger.warn("Badly formatted address", e); - this.client.close(1001, "Server Error"); - } - } - - @Override - public void onDispatchUnsubscribed(String channel) { - this.client.close(1001, "Closed"); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketAccountAuthenticator.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketAccountAuthenticator.java deleted file mode 100644 index c7dc07497..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketAccountAuthenticator.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import io.dropwizard.auth.basic.BasicCredentials; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.eclipse.jetty.websocket.api.UpgradeRequest; -import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.websocket.auth.WebSocketAuthenticator; - - -public class WebSocketAccountAuthenticator implements WebSocketAuthenticator { - - private final AccountAuthenticator accountAuthenticator; - - public WebSocketAccountAuthenticator(AccountAuthenticator accountAuthenticator) { - this.accountAuthenticator = accountAuthenticator; - } - - @Override - public AuthenticationResult authenticate(UpgradeRequest request) { - Map> parameters = request.getParameterMap(); - List usernames = parameters.get("login"); - List passwords = parameters.get("password"); - - if (usernames == null || usernames.size() == 0 || - passwords == null || passwords.size() == 0) { - return new AuthenticationResult<>(Optional.empty(), false); - } - - BasicCredentials credentials = new BasicCredentials(usernames.get(0).replace(" ", "+"), - passwords.get(0).replace(" ", "+")); - - return new AuthenticationResult<>(accountAuthenticator.authenticate(credentials), true); - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnection.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnection.java deleted file mode 100644 index 86557a96d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnection.java +++ /dev/null @@ -1,466 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.codahale.metrics.Histogram; -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.codahale.metrics.SharedMetricRegistries; -import com.google.common.annotations.VisibleForTesting; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Tags; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.atomic.LongAdder; -import org.apache.commons.lang3.StringUtils; -import org.reactivestreams.Publisher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.controllers.MessageController; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import org.whispersystems.textsecuregcm.metrics.MessageMetrics; -import org.whispersystems.textsecuregcm.metrics.MetricsUtil; -import org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil; -import org.whispersystems.textsecuregcm.push.DisplacedPresenceListener; -import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.MessageAvailabilityListener; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.util.Constants; -import org.whispersystems.textsecuregcm.util.HeaderUtils; -import org.whispersystems.websocket.WebSocketClient; -import org.whispersystems.websocket.messages.WebSocketResponseMessage; -import reactor.core.Disposable; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; - -public class WebSocketConnection implements MessageAvailabilityListener, DisplacedPresenceListener { - - private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); - private static final Histogram messageTime = metricRegistry.histogram( - name(MessageController.class, "message_delivery_duration")); - private static final Histogram primaryDeviceMessageTime = metricRegistry.histogram( - name(MessageController.class, "primary_device_message_delivery_duration")); - private static final Meter sendMessageMeter = metricRegistry.meter(name(WebSocketConnection.class, "send_message")); - private static final Meter messageAvailableMeter = metricRegistry.meter( - name(WebSocketConnection.class, "messagesAvailable")); - private static final Meter messagesPersistedMeter = metricRegistry.meter( - name(WebSocketConnection.class, "messagesPersisted")); - private static final Meter bytesSentMeter = metricRegistry.meter(name(WebSocketConnection.class, "bytes_sent")); - private static final Meter sendFailuresMeter = metricRegistry.meter(name(WebSocketConnection.class, "send_failures")); - - private static final String INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME = name(WebSocketConnection.class, - "initialQueueLength"); - private static final String INITIAL_QUEUE_DRAIN_TIMER_NAME = name(WebSocketConnection.class, "drainInitialQueue"); - private static final String SLOW_QUEUE_DRAIN_COUNTER_NAME = name(WebSocketConnection.class, "slowQueueDrain"); - private static final String QUEUE_DRAIN_RETRY_COUNTER_NAME = name(WebSocketConnection.class, "queueDrainRetry"); - private static final String DISPLACEMENT_COUNTER_NAME = name(WebSocketConnection.class, "displacement"); - private static final String NON_SUCCESS_RESPONSE_COUNTER_NAME = name(WebSocketConnection.class, - "clientNonSuccessResponse"); - private static final String CLIENT_CLOSED_MESSAGE_AVAILABLE_COUNTER_NAME = name(WebSocketConnection.class, - "messageAvailableAfterClientClosed"); - private static final String SEND_MESSAGES_FLUX_NAME = MetricsUtil.name(WebSocketConnection.class, - "sendMessages"); - private static final String SEND_MESSAGE_ERROR_COUNTER = MetricsUtil.name(WebSocketConnection.class, - "sendMessageError"); - private static final String STATUS_CODE_TAG = "status"; - private static final String STATUS_MESSAGE_TAG = "message"; - private static final String ERROR_TYPE_TAG = "errorType"; - - private static final long SLOW_DRAIN_THRESHOLD = 10_000; - - @VisibleForTesting - static final int MESSAGE_PUBLISHER_LIMIT_RATE = 100; - - @VisibleForTesting - static final int MAX_CONSECUTIVE_RETRIES = 5; - private static final long RETRY_DELAY_MILLIS = 1_000; - private static final int RETRY_DELAY_JITTER_MILLIS = 500; - - private static final int DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS = 5 * 60 * 1000; - - private static final Logger logger = LoggerFactory.getLogger(WebSocketConnection.class); - - private final ReceiptSender receiptSender; - private final MessagesManager messagesManager; - - private final AuthenticatedAccount auth; - private final Device device; - private final WebSocketClient client; - - private final int sendFuturesTimeoutMillis; - - private final ScheduledExecutorService scheduledExecutorService; - - private final Semaphore processStoredMessagesSemaphore = new Semaphore(1); - private final AtomicReference storedMessageState = new AtomicReference<>( - StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE); - private final AtomicBoolean sentInitialQueueEmptyMessage = new AtomicBoolean(false); - private final LongAdder sentMessageCounter = new LongAdder(); - private final AtomicLong queueDrainStartTime = new AtomicLong(); - private final AtomicInteger consecutiveRetries = new AtomicInteger(); - private final AtomicReference> retryFuture = new AtomicReference<>(); - private final AtomicReference messageSubscription = new AtomicReference<>(); - - private final Random random = new Random(); - private final Scheduler reactiveScheduler; - - private enum StoredMessageState { - EMPTY, - CACHED_NEW_MESSAGES_AVAILABLE, - PERSISTED_NEW_MESSAGES_AVAILABLE - } - - public WebSocketConnection(ReceiptSender receiptSender, - MessagesManager messagesManager, - AuthenticatedAccount auth, - Device device, - WebSocketClient client, - ScheduledExecutorService scheduledExecutorService) { - - this(receiptSender, - messagesManager, - auth, - device, - client, - scheduledExecutorService, - Schedulers.boundedElastic()); - } - - @VisibleForTesting - WebSocketConnection(ReceiptSender receiptSender, - MessagesManager messagesManager, - AuthenticatedAccount auth, - Device device, - WebSocketClient client, - ScheduledExecutorService scheduledExecutorService, - Scheduler reactiveScheduler) { - - this(receiptSender, - messagesManager, - auth, - device, - client, - DEFAULT_SEND_FUTURES_TIMEOUT_MILLIS, - scheduledExecutorService, - reactiveScheduler); - } - - @VisibleForTesting - WebSocketConnection(ReceiptSender receiptSender, - MessagesManager messagesManager, - AuthenticatedAccount auth, - Device device, - WebSocketClient client, - int sendFuturesTimeoutMillis, - ScheduledExecutorService scheduledExecutorService, - Scheduler reactiveScheduler) { - - this.receiptSender = receiptSender; - this.messagesManager = messagesManager; - this.auth = auth; - this.device = device; - this.client = client; - this.sendFuturesTimeoutMillis = sendFuturesTimeoutMillis; - this.scheduledExecutorService = scheduledExecutorService; - this.reactiveScheduler = reactiveScheduler; - } - - public void start() { - queueDrainStartTime.set(System.currentTimeMillis()); - processStoredMessages(); - } - - public void stop() { - final ScheduledFuture future = retryFuture.get(); - - if (future != null) { - future.cancel(false); - } - - final Disposable subscription = messageSubscription.get(); - if (subscription != null) { - subscription.dispose(); - } - - client.close(1000, "OK"); - } - - private CompletableFuture sendMessage(final Envelope message, StoredMessageInfo storedMessageInfo) { - // clear ephemeral field from the envelope - final Optional body = Optional.ofNullable(message.toBuilder().clearEphemeral().build().toByteArray()); - - sendMessageMeter.mark(); - sentMessageCounter.increment(); - bytesSentMeter.mark(body.map(bytes -> bytes.length).orElse(0)); - MessageMetrics.measureAccountEnvelopeUuidMismatches(auth.getAccount(), message); - - // X-Signal-Key: false must be sent until Android stops assuming it missing means true - return client.sendRequest("PUT", "/api/v1/message", - List.of(HeaderUtils.X_SIGNAL_KEY + ": false", HeaderUtils.getTimestampHeader()), body) - .whenComplete((ignored, throwable) -> { - if (throwable != null) { - sendFailuresMeter.mark(); - } - }).thenCompose(response -> { - final CompletableFuture result; - if (isSuccessResponse(response)) { - - result = messagesManager.delete(auth.getAccount().getUuid(), device.getId(), - storedMessageInfo.guid(), storedMessageInfo.serverTimestamp()); - - if (message.getType() != Envelope.Type.SERVER_DELIVERY_RECEIPT) { - recordMessageDeliveryDuration(message.getTimestamp(), device); - sendDeliveryReceiptFor(message); - } - } else { - final List tags = new ArrayList<>( - List.of( - Tag.of(STATUS_CODE_TAG, String.valueOf(response.getStatus())), - UserAgentTagUtil.getPlatformTag(client.getUserAgent()) - )); - - // TODO Remove this once we've identified the cause of message rejections from desktop clients - if (StringUtils.isNotBlank(response.getMessage())) { - tags.add(Tag.of(STATUS_MESSAGE_TAG, response.getMessage())); - } - - Metrics.counter(NON_SUCCESS_RESPONSE_COUNTER_NAME, tags).increment(); - - result = CompletableFuture.completedFuture(null); - } - - return result; - }); - } - - public static void recordMessageDeliveryDuration(long timestamp, Device messageDestinationDevice) { - final long messageDeliveryDuration = System.currentTimeMillis() - timestamp; - messageTime.update(messageDeliveryDuration); - if (messageDestinationDevice.isMaster()) { - primaryDeviceMessageTime.update(messageDeliveryDuration); - } - } - - private void sendDeliveryReceiptFor(Envelope message) { - if (!message.hasSourceUuid()) { - return; - } - - try { - receiptSender.sendReceipt(UUID.fromString(message.getDestinationUuid()), - auth.getAuthenticatedDevice().getId(), UUID.fromString(message.getSourceUuid()), - message.getTimestamp()); - } catch (IllegalArgumentException e) { - logger.error("Could not parse UUID: {}", message.getSourceUuid()); - } catch (Exception e) { - logger.warn("Failed to send receipt", e); - } - } - - private boolean isSuccessResponse(WebSocketResponseMessage response) { - return response != null && response.getStatus() >= 200 && response.getStatus() < 300; - } - - @VisibleForTesting - void processStoredMessages() { - if (processStoredMessagesSemaphore.tryAcquire()) { - final StoredMessageState state = storedMessageState.getAndSet(StoredMessageState.EMPTY); - final CompletableFuture queueCleared = new CompletableFuture<>(); - - sendMessages(state != StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE, queueCleared); - - setQueueClearedHandler(state, queueCleared); - } - } - - private void setQueueClearedHandler(final StoredMessageState state, final CompletableFuture queueCleared) { - - queueCleared.whenComplete((v, cause) -> { - if (cause == null) { - consecutiveRetries.set(0); - - if (sentInitialQueueEmptyMessage.compareAndSet(false, true)) { - final List tags = List.of( - UserAgentTagUtil.getPlatformTag(client.getUserAgent()) - ); - final long drainDuration = System.currentTimeMillis() - queueDrainStartTime.get(); - - Metrics.summary(INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME, tags).record(sentMessageCounter.sum()); - Metrics.timer(INITIAL_QUEUE_DRAIN_TIMER_NAME, tags).record(drainDuration, TimeUnit.MILLISECONDS); - - if (drainDuration > SLOW_DRAIN_THRESHOLD) { - Metrics.counter(SLOW_QUEUE_DRAIN_COUNTER_NAME, tags).increment(); - } - - client.sendRequest("PUT", "/api/v1/queue/empty", - Collections.singletonList(HeaderUtils.getTimestampHeader()), Optional.empty()); - } - } else { - storedMessageState.compareAndSet(StoredMessageState.EMPTY, state); - } - - processStoredMessagesSemaphore.release(); - - if (cause == null) { - if (storedMessageState.get() != StoredMessageState.EMPTY) { - processStoredMessages(); - } - } else { - if (client.isOpen()) { - - if (consecutiveRetries.incrementAndGet() > MAX_CONSECUTIVE_RETRIES) { - logger.warn("Max consecutive retries exceeded", cause); - client.close(1011, "Failed to retrieve messages"); - } else { - logger.debug("Failed to clear queue", cause); - final List tags = List.of(UserAgentTagUtil.getPlatformTag(client.getUserAgent())); - - Metrics.counter(QUEUE_DRAIN_RETRY_COUNTER_NAME, tags).increment(); - - final long delay = RETRY_DELAY_MILLIS + random.nextInt(RETRY_DELAY_JITTER_MILLIS); - retryFuture - .set(scheduledExecutorService.schedule(this::processStoredMessages, delay, TimeUnit.MILLISECONDS)); - } - } else { - logger.debug("Client disconnected before queue cleared"); - } - } - }); - } - - private void sendMessages(final boolean cachedMessagesOnly, final CompletableFuture queueCleared) { - - final Publisher messages = - messagesManager.getMessagesForDeviceReactive(auth.getAccount().getUuid(), device.getId(), cachedMessagesOnly); - - final Disposable subscription = Flux.from(messages) - .name(SEND_MESSAGES_FLUX_NAME) - .metrics() - .limitRate(MESSAGE_PUBLISHER_LIMIT_RATE) - .flatMapSequential(envelope -> - Mono.fromFuture(sendMessage(envelope) - .orTimeout(sendFuturesTimeoutMillis, TimeUnit.MILLISECONDS)) - .doOnError(e -> { - final String errorType; - if (!(e instanceof TimeoutException)) { - // TimeoutExceptions are expected, no need to log - logger.warn("Send message failed", e); - errorType = "other"; - } else { - errorType = "timeout"; - } - final Tags tags = Tags.of( - UserAgentTagUtil.getPlatformTag(client.getUserAgent()), - Tag.of(ERROR_TYPE_TAG, errorType)); - Metrics.counter(SEND_MESSAGE_ERROR_COUNTER, tags).increment(); - })) - .doOnError(queueCleared::completeExceptionally) - .doOnComplete(() -> queueCleared.complete(null)) - .subscribeOn(reactiveScheduler) - .subscribe(); - - messageSubscription.set(subscription); - } - - private CompletableFuture sendMessage(Envelope envelope) { - final UUID messageGuid = UUID.fromString(envelope.getServerGuid()); - - if (envelope.getStory() && !client.shouldDeliverStories()) { - messagesManager.delete(auth.getAccount().getUuid(), device.getId(), messageGuid, envelope.getServerTimestamp()); - - return CompletableFuture.completedFuture(null); - } else { - return sendMessage(envelope, new StoredMessageInfo(messageGuid, envelope.getServerTimestamp())); - } - } - - @Override - public boolean handleNewMessagesAvailable() { - if (!client.isOpen()) { - // The client may become closed without successful removal of references to the `MessageAvailabilityListener` - Metrics.counter(CLIENT_CLOSED_MESSAGE_AVAILABLE_COUNTER_NAME).increment(); - return false; - } - - messageAvailableMeter.mark(); - - storedMessageState.compareAndSet(StoredMessageState.EMPTY, StoredMessageState.CACHED_NEW_MESSAGES_AVAILABLE); - - processStoredMessages(); - - return true; - } - - @Override - public boolean handleMessagesPersisted() { - if (!client.isOpen()) { - // The client may become without successful removal of references to the `MessageAvailabilityListener` - Metrics.counter(CLIENT_CLOSED_MESSAGE_AVAILABLE_COUNTER_NAME).increment(); - return false; - } - messagesPersistedMeter.mark(); - - storedMessageState.set(StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE); - - processStoredMessages(); - - return true; - } - - @Override - public void handleDisplacement(final boolean connectedElsewhere) { - final Tags tags = Tags.of( - UserAgentTagUtil.getPlatformTag(client.getUserAgent()), - Tag.of("connectedElsewhere", String.valueOf(connectedElsewhere)) - ); - - Metrics.counter(DISPLACEMENT_COUNTER_NAME, tags).increment(); - - final int code; - final String message; - - if (connectedElsewhere) { - code = 4409; - message = "Connected elsewhere"; - } else { - code = 1000; - message = "OK"; - } - - try { - client.close(code, message); - } catch (final Exception e) { - logger.warn("Orderly close failed", e); - - client.hardDisconnectQuietly(); - } - } - - private record StoredMessageInfo(UUID guid, long serverTimestamp) { - - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebsocketAddress.java b/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebsocketAddress.java deleted file mode 100644 index df670064f..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebsocketAddress.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import org.whispersystems.textsecuregcm.storage.PubSubAddress; - -public class WebsocketAddress implements PubSubAddress { - - private final String number; - private final long deviceId; - - public WebsocketAddress(String number, long deviceId) { - this.number = number; - this.deviceId = deviceId; - } - - public WebsocketAddress(String serialized) throws InvalidWebsocketAddressException { - try { - String[] parts = serialized.split(":", 2); - - if (parts.length != 2) { - throw new InvalidWebsocketAddressException("Bad address: " + serialized); - } - - this.number = parts[0]; - this.deviceId = Long.parseLong(parts[1]); - } catch (NumberFormatException e) { - throw new InvalidWebsocketAddressException(e); - } - } - - public String getNumber() { - return number; - } - - public long getDeviceId() { - return deviceId; - } - - public String serialize() { - return number + ":" + deviceId; - } - - public String toString() { - return serialize(); - } - - @Override - public boolean equals(Object other) { - if (other == null) return false; - if (!(other instanceof WebsocketAddress)) return false; - - WebsocketAddress that = (WebsocketAddress)other; - - return - this.number.equals(that.number) && - this.deviceId == that.deviceId; - } - - @Override - public int hashCode() { - return number.hashCode() ^ (int)deviceId; - } - -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/AssignUsernameCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/AssignUsernameCommand.java deleted file mode 100644 index 3dabc6d5a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/AssignUsernameCommand.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.InstanceProfileCredentialsProvider; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.fasterxml.jackson.databind.DeserializationFeature; -import io.dropwizard.Application; -import io.dropwizard.cli.EnvironmentCommand; -import io.dropwizard.setup.Environment; -import io.lettuce.core.resource.ClientResources; -import java.time.Clock; -import java.util.Base64; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.whispersystems.textsecuregcm.WhisperServerConfiguration; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.controllers.SecureBackupController; -import org.whispersystems.textsecuregcm.controllers.SecureStorageController; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Accounts; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DeletedAccounts; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.storage.MessagesCache; -import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; -import org.whispersystems.textsecuregcm.storage.Profiles; -import org.whispersystems.textsecuregcm.storage.ProfilesManager; -import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; -import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; -import org.whispersystems.textsecuregcm.storage.VerificationCodeStore; -import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; - -public class AssignUsernameCommand extends EnvironmentCommand { - - public AssignUsernameCommand() { - super(new Application<>() { - @Override - public void run(WhisperServerConfiguration configuration, Environment environment) { - - } - }, "assign-username-hash", "assign a username hash to an account"); - } - - @Override - public void configure(Subparser subparser) { - super.configure(subparser); - - subparser.addArgument("-u", "--usernameHash") - .dest("usernameHash") - .type(String.class) - .required(true) - .help("The username hash to assign"); - - subparser.addArgument("-a", "--aci") - .dest("aci") - .type(String.class) - .required(true) - .help("The ACI of the account to which to assign the username"); - } - - @Override - protected void run(Environment environment, Namespace namespace, - WhisperServerConfiguration configuration) - throws Exception { - environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - - ClientResources redisClusterClientResources = ClientResources.builder().build(); - - FaultTolerantRedisCluster cacheCluster = new FaultTolerantRedisCluster("main_cache_cluster", - configuration.getCacheClusterConfiguration(), redisClusterClientResources); - - ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle() - .executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build(); - ExecutorService messageDeletionExecutor = environment.lifecycle() - .executorService(name(getClass(), "messageDeletion-%d")).maxThreads(4).build(); - ExecutorService backupServiceExecutor = environment.lifecycle() - .executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build(); - ExecutorService storageServiceExecutor = environment.lifecycle() - .executorService(name(getClass(), "storageService-%d")).maxThreads(8).minThreads(1).build(); - - ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator( - configuration.getSecureBackupServiceConfiguration()); - ExternalServiceCredentialsGenerator storageCredentialsGenerator = SecureStorageController.credentialsGenerator( - configuration.getSecureStorageServiceConfiguration()); - - DynamicConfigurationManager dynamicConfigurationManager = new DynamicConfigurationManager<>( - configuration.getAppConfig().getApplication(), configuration.getAppConfig().getEnvironment(), - configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class); - dynamicConfigurationManager.start(); - - ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager( - dynamicConfigurationManager); - - DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient( - configuration.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client( - configuration.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - AmazonDynamoDB deletedAccountsLockDynamoDbClient = AmazonDynamoDBClientBuilder.standard() - .withRegion(configuration.getDynamoDbClientConfiguration().getRegion()) - .withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout( - ((int) configuration.getDynamoDbClientConfiguration().getClientExecutionTimeout() - .toMillis())) - .withRequestTimeout( - (int) configuration.getDynamoDbClientConfiguration().getClientRequestTimeout() - .toMillis())) - .withCredentials(InstanceProfileCredentialsProvider.getInstance()) - .build(); - - DeletedAccounts deletedAccounts = new DeletedAccounts(dynamoDbClient, - configuration.getDynamoDbTables().getDeletedAccounts().getTableName(), - configuration.getDynamoDbTables().getDeletedAccounts().getNeedsReconciliationIndexName()); - VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient, - configuration.getDynamoDbTables().getPendingAccounts().getTableName()); - RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords( - configuration.getDynamoDbTables().getRegistrationRecovery().getTableName(), - configuration.getDynamoDbTables().getRegistrationRecovery().getExpiration(), - dynamoDbClient, - dynamoDbAsyncClient - ); - - RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords); - - Accounts accounts = new Accounts( - dynamoDbClient, - dynamoDbAsyncClient, - configuration.getDynamoDbTables().getAccounts().getTableName(), - configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(), - configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(), - configuration.getDynamoDbTables().getAccounts().getUsernamesTableName(), - configuration.getDynamoDbTables().getAccounts().getScanPageSize()); - PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient, - configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName()); - Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient, - configuration.getDynamoDbTables().getProfiles().getTableName()); - ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient, - configuration.getDynamoDbTables().getReservedUsernames().getTableName()); - Keys keys = new Keys(dynamoDbClient, - configuration.getDynamoDbTables().getKeys().getTableName()); - MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient, - configuration.getDynamoDbTables().getMessages().getTableName(), - configuration.getDynamoDbTables().getMessages().getExpiration(), - messageDeletionExecutor); - FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster", - configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster", - configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence_cluster", - configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters", - configuration.getRateLimitersCluster(), redisClusterClientResources); - SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor, - configuration.getSecureBackupServiceConfiguration()); - SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, - storageServiceExecutor, configuration.getSecureStorageServiceConfiguration()); - ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, - Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor); - MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster, - Clock.systemUTC(), keyspaceNotificationDispatchExecutor, messageDeletionExecutor); - DirectoryQueue directoryQueue = new DirectoryQueue( - configuration.getDirectoryConfiguration().getSqsConfiguration()); - ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster); - ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbClient, - configuration.getDynamoDbTables().getReportMessage().getTableName(), - configuration.getReportMessageConfiguration().getReportTtl()); - ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, - configuration.getReportMessageConfiguration().getCounterTtl()); - MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, - reportMessageManager, messageDeletionExecutor); - DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts, - deletedAccountsLockDynamoDbClient, - configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName()); - StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts); - AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster, - deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager, - pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, - experimentEnrollmentManager, registrationRecoveryPasswordsManager, Clock.systemUTC()); - - final String usernameHash = namespace.getString("usernameHash"); - final UUID accountIdentifier = UUID.fromString(namespace.getString("aci")); - - accountsManager.getByAccountIdentifier(accountIdentifier).ifPresentOrElse(account -> { - try { - final AccountsManager.UsernameReservation reservation = accountsManager.reserveUsernameHash(account, - List.of(Base64.getUrlDecoder().decode(usernameHash))); - final Account result = accountsManager.confirmReservedUsernameHash(account, Base64.getUrlDecoder().decode(usernameHash)); - System.out.println("New username hash: " + usernameHash); - } catch (final UsernameHashNotAvailableException e) { - throw new IllegalArgumentException("Username hash already taken"); - } catch (final UsernameReservationNotFoundException e) { - throw new IllegalArgumentException("Username hash reservation not found"); - } - }, - () -> { - throw new IllegalArgumentException("Account not found"); - }); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CertificateCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CertificateCommand.java deleted file mode 100644 index c0186c25a..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CertificateCommand.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import com.google.common.base.MoreObjects; -import com.google.protobuf.ByteString; -import net.sourceforge.argparse4j.impl.Arguments; -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.signal.libsignal.protocol.ecc.Curve; -import org.signal.libsignal.protocol.ecc.ECKeyPair; -import org.signal.libsignal.protocol.ecc.ECPrivateKey; -import org.whispersystems.textsecuregcm.entities.MessageProtos; - -import java.io.IOException; -import java.security.InvalidKeyException; -import java.util.Base64; -import java.util.Set; - -import io.dropwizard.cli.Command; -import io.dropwizard.setup.Bootstrap; - -public class CertificateCommand extends Command { - - private static final Set RESERVED_CERTIFICATE_IDS = Set.of( - 0xdeadc357 // Reserved for testing; see https://github.com/signalapp/libsignal-client/pull/118 - ); - - public CertificateCommand() { - super("certificate", "Generates server certificates for unidentified delivery"); - } - - @Override - public void configure(Subparser subparser) { - subparser.addArgument("-ca", "--ca") - .dest("ca") - .action(Arguments.storeTrue()) - .setDefault(Boolean.FALSE) - .help("Generate CA parameters"); - - subparser.addArgument("-k", "--key") - .dest("key") - .type(String.class) - .help("The CA private signing key"); - - subparser.addArgument("-i", "--id") - .dest("keyId") - .type(Integer.class) - .help("The key ID to create"); - } - - @Override - public void run(Bootstrap bootstrap, Namespace namespace) throws Exception { - if (MoreObjects.firstNonNull(namespace.getBoolean("ca"), false)) runCaCommand(); - else runCertificateCommand(namespace); - } - - private void runCaCommand() { - ECKeyPair keyPair = Curve.generateKeyPair(); - System.out.println("Public key : " + Base64.getEncoder().encodeToString(keyPair.getPublicKey().serialize())); - System.out.println("Private key: " + Base64.getEncoder().encodeToString(keyPair.getPrivateKey().serialize())); - } - - private void runCertificateCommand(Namespace namespace) throws IOException, InvalidKeyException { - if (namespace.getString("key") == null) { - System.out.println("No key specified!"); - return; - } - - if (namespace.getInt("keyId") == null) { - System.out.print("No key id specified!"); - return; - } - - ECPrivateKey key = Curve.decodePrivatePoint(Base64.getDecoder().decode(namespace.getString("key"))); - int keyId = namespace.getInt("keyId"); - - if (RESERVED_CERTIFICATE_IDS.contains(keyId)) { - throw new IllegalArgumentException( - String.format("Key ID %08x has been reserved or revoked and may not be used in new certificates.", keyId)); - } - - ECKeyPair keyPair = Curve.generateKeyPair(); - - byte[] certificate = MessageProtos.ServerCertificate.Certificate.newBuilder() - .setId(keyId) - .setKey(ByteString.copyFrom(keyPair.getPublicKey().serialize())) - .build() - .toByteArray(); - - byte[] signature; - try { - signature = Curve.calculateSignature(key, certificate); - } catch (org.signal.libsignal.protocol.InvalidKeyException e) { - throw new InvalidKeyException(e); - } - - byte[] signedCertificate = MessageProtos.ServerCertificate.newBuilder() - .setCertificate(ByteString.copyFrom(certificate)) - .setSignature(ByteString.copyFrom(signature)) - .build() - .toByteArray(); - - System.out.println("Certificate: " + Base64.getEncoder().encodeToString(signedCertificate)); - System.out.println("Private key: " + Base64.getEncoder().encodeToString(keyPair.getPrivateKey().serialize())); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CheckDynamicConfigurationCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/CheckDynamicConfigurationCommand.java deleted file mode 100644 index aeaaef826..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/CheckDynamicConfigurationCommand.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import io.dropwizard.cli.Command; -import io.dropwizard.setup.Bootstrap; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -public class CheckDynamicConfigurationCommand extends Command { - - public CheckDynamicConfigurationCommand() { - super("check-dynamic-config", "Check validity of a dynamic configuration file"); - } - - @Override - public void configure(final Subparser subparser) { - subparser.addArgument("file") - .type(String.class) - .required(true) - .help("Dynamic configuration file to check"); - - subparser.addArgument("-c", "--class") - .type(String.class) - .nargs("*") - .setDefault(DynamicConfiguration.class.getCanonicalName()); - } - - @Override - public void run(final Bootstrap bootstrap, final Namespace namespace) throws Exception { - final Path path = Path.of(namespace.getString("file")); - - final List> configurationClasses; - - if (namespace.get("class") instanceof List) { - final List> classesFromArguments = new ArrayList<>(); - - for (final Object object : namespace.getList("class")) { - classesFromArguments.add(Class.forName(object.toString())); - } - - configurationClasses = classesFromArguments; - } else { - configurationClasses = List.of(Class.forName(namespace.getString("class"))); - } - - for (final Class configurationClass : configurationClasses) { - if (DynamicConfigurationManager.parseConfiguration(Files.readString(path), configurationClass).isPresent()) { - System.out.println(configurationClass.getSimpleName() + ": dynamic configuration file at " + path + " is valid"); - } else { - System.err.println(configurationClass.getSimpleName() + ": dynamic configuration file at " + path + " is not valid"); - } - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/DeleteUserCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/DeleteUserCommand.java deleted file mode 100644 index cad61691c..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/DeleteUserCommand.java +++ /dev/null @@ -1,227 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.InstanceProfileCredentialsProvider; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.fasterxml.jackson.databind.DeserializationFeature; -import io.dropwizard.Application; -import io.dropwizard.cli.EnvironmentCommand; -import io.dropwizard.setup.Environment; -import io.lettuce.core.resource.ClientResources; -import java.time.Clock; -import java.util.Optional; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.textsecuregcm.WhisperServerConfiguration; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.controllers.SecureBackupController; -import org.whispersystems.textsecuregcm.controllers.SecureStorageController; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Accounts; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.AccountsManager.DeletionReason; -import org.whispersystems.textsecuregcm.storage.DeletedAccounts; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.storage.MessagesCache; -import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; -import org.whispersystems.textsecuregcm.storage.Profiles; -import org.whispersystems.textsecuregcm.storage.ProfilesManager; -import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.storage.VerificationCodeStore; -import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; - -public class DeleteUserCommand extends EnvironmentCommand { - - private final Logger logger = LoggerFactory.getLogger(DeleteUserCommand.class); - - public DeleteUserCommand() { - super(new Application<>() { - @Override - public void run(WhisperServerConfiguration configuration, Environment environment) { - - } - }, "rmuser", "remove user"); - } - - @Override - public void configure(Subparser subparser) { - super.configure(subparser); - subparser.addArgument("-u", "--user") - .dest("user") - .type(String.class) - .required(true) - .help("The user to remove"); - } - - @Override - protected void run(Environment environment, Namespace namespace, - WhisperServerConfiguration configuration) - throws Exception - { - try { - Clock clock = Clock.systemUTC(); - String[] users = namespace.getString("user").split(","); - - environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - - ClientResources redisClusterClientResources = ClientResources.builder().build(); - - FaultTolerantRedisCluster cacheCluster = new FaultTolerantRedisCluster("main_cache_cluster", - configuration.getCacheClusterConfiguration(), redisClusterClientResources); - - ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle() - .executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build(); - ExecutorService messageDeletionExecutor = environment.lifecycle() - .executorService(name(getClass(), "messageDeletion-%d")).maxThreads(4).build(); - ExecutorService backupServiceExecutor = environment.lifecycle() - .executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build(); - ExecutorService storageServiceExecutor = environment.lifecycle() - .executorService(name(getClass(), "storageService-%d")).maxThreads(8).minThreads(1).build(); - - ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator( - configuration.getSecureBackupServiceConfiguration()); - ExternalServiceCredentialsGenerator storageCredentialsGenerator = SecureStorageController.credentialsGenerator( - configuration.getSecureStorageServiceConfiguration()); - - DynamicConfigurationManager dynamicConfigurationManager = new DynamicConfigurationManager<>( - configuration.getAppConfig().getApplication(), configuration.getAppConfig().getEnvironment(), - configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class); - dynamicConfigurationManager.start(); - - ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager( - dynamicConfigurationManager); - - DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient( - configuration.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client( - configuration.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - AmazonDynamoDB deletedAccountsLockDynamoDbClient = AmazonDynamoDBClientBuilder.standard() - .withRegion(configuration.getDynamoDbClientConfiguration().getRegion()) - .withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout( - ((int) configuration.getDynamoDbClientConfiguration().getClientExecutionTimeout() - .toMillis())) - .withRequestTimeout( - (int) configuration.getDynamoDbClientConfiguration().getClientRequestTimeout() - .toMillis())) - .withCredentials(InstanceProfileCredentialsProvider.getInstance()) - .build(); - - DeletedAccounts deletedAccounts = new DeletedAccounts(dynamoDbClient, - configuration.getDynamoDbTables().getDeletedAccounts().getTableName(), - configuration.getDynamoDbTables().getDeletedAccounts().getNeedsReconciliationIndexName()); - VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient, - configuration.getDynamoDbTables().getPendingAccounts().getTableName()); - RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords( - configuration.getDynamoDbTables().getRegistrationRecovery().getTableName(), - configuration.getDynamoDbTables().getRegistrationRecovery().getExpiration(), - dynamoDbClient, - dynamoDbAsyncClient - ); - - RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords); - - Accounts accounts = new Accounts( - dynamoDbClient, - dynamoDbAsyncClient, - configuration.getDynamoDbTables().getAccounts().getTableName(), - configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(), - configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(), - configuration.getDynamoDbTables().getAccounts().getUsernamesTableName(), - configuration.getDynamoDbTables().getAccounts().getScanPageSize()); - PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient, - configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName()); - Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient, - configuration.getDynamoDbTables().getProfiles().getTableName()); - ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient, - configuration.getDynamoDbTables().getReservedUsernames().getTableName()); - Keys keys = new Keys(dynamoDbClient, - configuration.getDynamoDbTables().getKeys().getTableName()); - MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient, - configuration.getDynamoDbTables().getMessages().getTableName(), - configuration.getDynamoDbTables().getMessages().getExpiration(), - messageDeletionExecutor); - FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster", - configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster", - configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence_cluster", - configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters", - configuration.getRateLimitersCluster(), redisClusterClientResources); - SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor, - configuration.getSecureBackupServiceConfiguration()); - SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, - storageServiceExecutor, configuration.getSecureStorageServiceConfiguration()); - ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, - Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor); - MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster, - Clock.systemUTC(), keyspaceNotificationDispatchExecutor, messageDeletionExecutor); - DirectoryQueue directoryQueue = new DirectoryQueue( - configuration.getDirectoryConfiguration().getSqsConfiguration()); - ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster); - ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbClient, - configuration.getDynamoDbTables().getReportMessage().getTableName(), - configuration.getReportMessageConfiguration().getReportTtl()); - ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, - configuration.getReportMessageConfiguration().getCounterTtl()); - MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, - reportMessageManager, messageDeletionExecutor); - DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts, - deletedAccountsLockDynamoDbClient, - configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName()); - StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts); - AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster, - deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager, - pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, - experimentEnrollmentManager, registrationRecoveryPasswordsManager, clock); - - for (String user : users) { - Optional account = accountsManager.getByE164(user); - - if (account.isPresent()) { - accountsManager.delete(account.get(), DeletionReason.ADMIN_DELETED); - logger.warn("Removed " + account.get().getNumber()); - } else { - logger.warn("Account not found"); - } - } - } catch (Exception ex) { - logger.warn("Removal Exception", ex); - throw new RuntimeException(ex); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ReserveUsernameCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ReserveUsernameCommand.java deleted file mode 100644 index 93be67a2d..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ReserveUsernameCommand.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import io.dropwizard.cli.ConfiguredCommand; -import io.dropwizard.setup.Bootstrap; -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.whispersystems.textsecuregcm.WhisperServerConfiguration; -import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames; -import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import java.util.UUID; -import java.util.regex.Pattern; - -public class ReserveUsernameCommand extends ConfiguredCommand { - - public ReserveUsernameCommand() { - super("reserve-username", "Reserve a username pattern for a specific account identifier"); - } - - @Override - public void configure(final Subparser subparser) { - super.configure(subparser); - - subparser.addArgument("-p", "--pattern") - .dest("pattern") - .type(String.class) - .required(true); - - subparser.addArgument("-u", "--uuid") - .dest("uuid") - .type(String.class) - .required(true); - } - - @Override - protected void run(final Bootstrap bootstrap, final Namespace namespace, - final WhisperServerConfiguration config) throws Exception { - - final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(config.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - final ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient, - config.getDynamoDbTables().getReservedUsernames().getTableName()); - - final String pattern = namespace.getString("pattern").trim(); - - try { - Pattern.compile(pattern); - } catch (final Exception e) { - throw new IllegalArgumentException("Bad pattern: " + pattern, e); - } - - final UUID aci = UUID.fromString(namespace.getString("uuid").trim()); - - prohibitedUsernames.prohibitUsername(pattern, aci); - - System.out.format("Reserved %s for account %s\n", pattern, aci); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ServerVersionCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ServerVersionCommand.java deleted file mode 100644 index efe12a668..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ServerVersionCommand.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import io.dropwizard.cli.Command; -import io.dropwizard.setup.Bootstrap; -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.whispersystems.textsecuregcm.WhisperServerVersion; - -public class ServerVersionCommand extends Command { - - public ServerVersionCommand() { - super("version", "Print the version of the service"); - } - - @Override - public void configure(final Subparser subparser) { - } - - @Override - public void run(final Bootstrap bootstrap, final Namespace namespace) throws Exception { - System.out.println(WhisperServerVersion.getServerVersion()); - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetCrawlerAccelerationTask.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetCrawlerAccelerationTask.java deleted file mode 100644 index a7cd2e560..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetCrawlerAccelerationTask.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import io.dropwizard.servlets.tasks.Task; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerCache; - -import java.io.PrintWriter; -import java.util.List; -import java.util.Map; - -public class SetCrawlerAccelerationTask extends Task { - - private final AccountDatabaseCrawlerCache crawlerCache; - - public SetCrawlerAccelerationTask(final AccountDatabaseCrawlerCache crawlerCache) { - super("set-crawler-accelerated"); - - this.crawlerCache = crawlerCache; - } - - @Override - public void execute(final Map> parameters, final PrintWriter out) { - if (parameters.containsKey("accelerated") && parameters.get("accelerated").size() == 1) { - final boolean accelerated = "true".equalsIgnoreCase(parameters.get("accelerated").get(0)); - - crawlerCache.setAccelerated(accelerated); - out.println("Set accelerated: " + accelerated); - } else { - out.println("Usage: set-crawler-accelerated?accelerated=[true|false]"); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetRequestLoggingEnabledTask.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetRequestLoggingEnabledTask.java deleted file mode 100644 index 4c0d277dd..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetRequestLoggingEnabledTask.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import io.dropwizard.servlets.tasks.Task; -import org.whispersystems.textsecuregcm.util.logging.RequestLogManager; - -import java.io.PrintWriter; -import java.util.List; -import java.util.Map; - -public class SetRequestLoggingEnabledTask extends Task { - - public SetRequestLoggingEnabledTask() { - super("set-request-logging-enabled"); - } - - @Override - public void execute(final Map> parameters, final PrintWriter out) { - if (parameters.containsKey("enabled") && parameters.get("enabled").size() == 1) { - final boolean enabled = Boolean.parseBoolean(parameters.get("enabled").get(0)); - - RequestLogManager.setRequestLoggingEnabled(enabled); - - if (enabled) { - out.println("Request logging now enabled"); - } else { - out.println("Request logging now disabled"); - } - } else { - out.println("Usage: set-request-logging-enabled?enabled=[true|false]"); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java deleted file mode 100644 index 0cebb31c8..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import static com.codahale.metrics.MetricRegistry.name; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.InstanceProfileCredentialsProvider; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.fasterxml.jackson.databind.DeserializationFeature; -import io.dropwizard.Application; -import io.dropwizard.cli.EnvironmentCommand; -import io.dropwizard.setup.Environment; -import io.lettuce.core.resource.ClientResources; -import java.time.Clock; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.whispersystems.textsecuregcm.WhisperServerConfiguration; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.controllers.SecureBackupController; -import org.whispersystems.textsecuregcm.controllers.SecureStorageController; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Accounts; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DeletedAccounts; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.storage.MessagesCache; -import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers; -import org.whispersystems.textsecuregcm.storage.Profiles; -import org.whispersystems.textsecuregcm.storage.ProfilesManager; -import org.whispersystems.textsecuregcm.storage.ProhibitedUsernames; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.storage.VerificationCodeStore; -import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; - -public class SetUserDiscoverabilityCommand extends EnvironmentCommand { - - public SetUserDiscoverabilityCommand() { - - super(new Application<>() { - @Override - public void run(final WhisperServerConfiguration whisperServerConfiguration, final Environment environment) { - } - }, "set-discoverability", "sets whether a user should be discoverable in CDS"); - } - - @Override - public void configure(final Subparser subparser) { - super.configure(subparser); - - subparser.addArgument("-u", "--user") - .dest("user") - .type(String.class) - .required(true) - .help("the user (UUID or E164) for whom to change discoverability"); - - subparser.addArgument("-d", "--discoverable") - .dest("discoverable") - .type(Boolean.class) - .required(true) - .help("whether the user should be discoverable in CDS"); - } - - @Override - protected void run(final Environment environment, - final Namespace namespace, - final WhisperServerConfiguration configuration) throws Exception { - - try { - Clock clock = Clock.systemUTC(); - environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - - ClientResources redisClusterClientResources = ClientResources.builder().build(); - - FaultTolerantRedisCluster cacheCluster = new FaultTolerantRedisCluster("main_cache_cluster", - configuration.getCacheClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster rateLimitersCluster = new FaultTolerantRedisCluster("rate_limiters", - configuration.getRateLimitersCluster(), redisClusterClientResources); - - ExecutorService keyspaceNotificationDispatchExecutor = environment.lifecycle() - .executorService(name(getClass(), "keyspaceNotification-%d")).maxThreads(4).build(); - ExecutorService messageDeletionExecutor = environment.lifecycle() - .executorService(name(getClass(), "messageDeletion-%d")).maxThreads(4).build(); - ExecutorService backupServiceExecutor = environment.lifecycle() - .executorService(name(getClass(), "backupService-%d")).maxThreads(8).minThreads(1).build(); - ExecutorService storageServiceExecutor = environment.lifecycle() - .executorService(name(getClass(), "storageService-%d")).maxThreads(8).minThreads(1).build(); - - ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator( - configuration.getSecureBackupServiceConfiguration()); - ExternalServiceCredentialsGenerator storageCredentialsGenerator = SecureStorageController.credentialsGenerator( - configuration.getSecureStorageServiceConfiguration()); - - DynamicConfigurationManager dynamicConfigurationManager = new DynamicConfigurationManager<>( - configuration.getAppConfig().getApplication(), configuration.getAppConfig().getEnvironment(), - configuration.getAppConfig().getConfigurationName(), DynamicConfiguration.class); - dynamicConfigurationManager.start(); - - ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager( - dynamicConfigurationManager); - - DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient( - configuration.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client( - configuration.getDynamoDbClientConfiguration(), - software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider.create()); - - AmazonDynamoDB deletedAccountsLockDynamoDbClient = AmazonDynamoDBClientBuilder.standard() - .withRegion(configuration.getDynamoDbClientConfiguration().getRegion()) - .withClientConfiguration(new ClientConfiguration().withClientExecutionTimeout( - ((int) configuration.getDynamoDbClientConfiguration().getClientExecutionTimeout() - .toMillis())) - .withRequestTimeout( - (int) configuration.getDynamoDbClientConfiguration().getClientRequestTimeout() - .toMillis())) - .withCredentials(InstanceProfileCredentialsProvider.getInstance()) - .build(); - - DeletedAccounts deletedAccounts = new DeletedAccounts(dynamoDbClient, - configuration.getDynamoDbTables().getDeletedAccounts().getTableName(), - configuration.getDynamoDbTables().getDeletedAccounts().getNeedsReconciliationIndexName()); - VerificationCodeStore pendingAccounts = new VerificationCodeStore(dynamoDbClient, - configuration.getDynamoDbTables().getPendingAccounts().getTableName()); - RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords( - configuration.getDynamoDbTables().getRegistrationRecovery().getTableName(), - configuration.getDynamoDbTables().getRegistrationRecovery().getExpiration(), - dynamoDbClient, - dynamoDbAsyncClient - ); - - RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords); - - Accounts accounts = new Accounts( - dynamoDbClient, - dynamoDbAsyncClient, - configuration.getDynamoDbTables().getAccounts().getTableName(), - configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(), - configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(), - configuration.getDynamoDbTables().getAccounts().getUsernamesTableName(), - configuration.getDynamoDbTables().getAccounts().getScanPageSize()); - PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbClient, - configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName()); - Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient, - configuration.getDynamoDbTables().getProfiles().getTableName()); - ProhibitedUsernames prohibitedUsernames = new ProhibitedUsernames(dynamoDbClient, - configuration.getDynamoDbTables().getReservedUsernames().getTableName()); - Keys keys = new Keys(dynamoDbClient, - configuration.getDynamoDbTables().getKeys().getTableName()); - MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient, - configuration.getDynamoDbTables().getMessages().getTableName(), - configuration.getDynamoDbTables().getMessages().getExpiration(), - messageDeletionExecutor); - FaultTolerantRedisCluster messageInsertCacheCluster = new FaultTolerantRedisCluster("message_insert_cluster", - configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster messageReadDeleteCluster = new FaultTolerantRedisCluster("message_read_delete_cluster", - configuration.getMessageCacheConfiguration().getRedisClusterConfiguration(), redisClusterClientResources); - FaultTolerantRedisCluster clientPresenceCluster = new FaultTolerantRedisCluster("client_presence", - configuration.getClientPresenceClusterConfiguration(), redisClusterClientResources); - SecureBackupClient secureBackupClient = new SecureBackupClient(backupCredentialsGenerator, backupServiceExecutor, - configuration.getSecureBackupServiceConfiguration()); - SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator, - storageServiceExecutor, configuration.getSecureStorageServiceConfiguration()); - ClientPresenceManager clientPresenceManager = new ClientPresenceManager(clientPresenceCluster, - Executors.newSingleThreadScheduledExecutor(), keyspaceNotificationDispatchExecutor); - MessagesCache messagesCache = new MessagesCache(messageInsertCacheCluster, messageReadDeleteCluster, - Clock.systemUTC(), keyspaceNotificationDispatchExecutor, messageDeletionExecutor); - DirectoryQueue directoryQueue = new DirectoryQueue( - configuration.getDirectoryConfiguration().getSqsConfiguration()); - ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster); - ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbClient, - configuration.getDynamoDbTables().getReportMessage().getTableName(), - configuration.getReportMessageConfiguration().getReportTtl()); - ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster, - configuration.getReportMessageConfiguration().getCounterTtl()); - MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, - reportMessageManager, messageDeletionExecutor); - DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts, - deletedAccountsLockDynamoDbClient, - configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName()); - StoredVerificationCodeManager pendingAccountsManager = new StoredVerificationCodeManager(pendingAccounts); - AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster, - deletedAccountsManager, directoryQueue, keys, messagesManager, profilesManager, - pendingAccountsManager, secureStorageClient, secureBackupClient, clientPresenceManager, - experimentEnrollmentManager, registrationRecoveryPasswordsManager, clock); - - Optional maybeAccount; - - try { - maybeAccount = accountsManager.getByAccountIdentifier(UUID.fromString(namespace.getString("user"))); - } catch (final IllegalArgumentException e) { - maybeAccount = accountsManager.getByE164(namespace.getString("user")); - } - - maybeAccount.ifPresentOrElse(account -> { - final boolean initiallyDiscoverable = account.isDiscoverableByPhoneNumber(); - accountsManager.update(account, a -> a.setDiscoverableByPhoneNumber(namespace.getBoolean("discoverable"))); - - System.out.format("Set discoverability flag for %s to %s (was previously %s)\n", - namespace.getString("user"), - namespace.getBoolean("discoverable"), - initiallyDiscoverable); - }, - () -> System.err.println("User not found: " + namespace.getString("user"))); - } catch (final Exception e) { - System.err.println("Failed to update discoverability setting for " + namespace.getString("user")); - e.printStackTrace(); - } - } -} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java b/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java deleted file mode 100644 index 3cb85d4de..000000000 --- a/service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.workers; - -import net.sourceforge.argparse4j.inf.Namespace; -import net.sourceforge.argparse4j.inf.Subparser; -import org.signal.libsignal.zkgroup.ServerPublicParams; -import org.signal.libsignal.zkgroup.ServerSecretParams; - -import io.dropwizard.cli.Command; -import io.dropwizard.setup.Bootstrap; -import java.util.Base64; - -public class ZkParamsCommand extends Command { - - public ZkParamsCommand() { - super("zkparams", "Generates server zkparams"); - } - - @Override - public void configure(Subparser subparser) { - - } - - @Override - public void run(Bootstrap bootstrap, Namespace namespace) throws Exception { - ServerSecretParams serverSecretParams = ServerSecretParams.generate(); - ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); - - System.out.println("Public: " + Base64.getEncoder().withoutPadding().encodeToString(serverPublicParams.serialize())); - System.out.println("Private: " + Base64.getEncoder().withoutPadding().encodeToString(serverSecretParams.serialize())); - } - -} diff --git a/service/src/main/proto/PubSubMessage.proto b/service/src/main/proto/PubSubMessage.proto deleted file mode 100644 index de8dd31db..000000000 --- a/service/src/main/proto/PubSubMessage.proto +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright 2014 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -syntax = "proto2"; - -package textsecure; - -option java_package = "org.whispersystems.textsecuregcm.storage"; -option java_outer_classname = "PubSubProtos"; - -message PubSubMessage { - enum Type { - UNKNOWN = 0; - QUERY_DB = 1; - DELIVER = 2; - KEEPALIVE = 3; - CLOSE = 4; - CONNECTED = 5; - } - - optional Type type = 1; - optional bytes content = 2; -} diff --git a/service/src/main/proto/RegistrationService.proto b/service/src/main/proto/RegistrationService.proto deleted file mode 100644 index deab91865..000000000 --- a/service/src/main/proto/RegistrationService.proto +++ /dev/null @@ -1,343 +0,0 @@ -syntax = "proto3"; - -option java_multiple_files = true; - -package org.signal.registration.rpc; - -service RegistrationService { - /** - * Create a new registration session for a given destination phone number. - */ - rpc create_session (CreateRegistrationSessionRequest) returns (CreateRegistrationSessionResponse) {} - - /** - * Retrieves session metadata for a given session. - */ - rpc get_session_metadata (GetRegistrationSessionMetadataRequest) returns (GetRegistrationSessionMetadataResponse) {} - - /** - * Sends a verification code to a destination phone number within the context - * of a previously-created registration session. - */ - rpc send_verification_code (SendVerificationCodeRequest) returns (SendVerificationCodeResponse) {} - - /** - * Checks a client-provided verification code for a given registration - * session. - */ - rpc check_verification_code (CheckVerificationCodeRequest) returns (CheckVerificationCodeResponse) {} -} - -message CreateRegistrationSessionRequest { - /** - * The phone number for which to create a new registration session. - */ - uint64 e164 = 1; -} - -message CreateRegistrationSessionResponse { - oneof response { - /** - * Metadata for the newly-created session. - */ - RegistrationSessionMetadata session_metadata = 1; - - /** - * A response explaining why a session could not be created as requested. - */ - CreateRegistrationSessionError error = 2; - } -} - -message RegistrationSessionMetadata { - /** - * An opaque sequence of bytes that uniquely identifies the registration - * session associated with this registration attempt. - */ - bytes session_id = 1; - - /** - * Indicates whether a valid verification code has been submitted in the scope - * of this session. - */ - bool verified = 2; - - /** - * The phone number associated with this registration session. - */ - uint64 e164 = 3; -} - -message CreateRegistrationSessionError { - /** - * The type of error that prevented a session from being created. - */ - CreateRegistrationSessionErrorType error_type = 1; - - /** - * Indicates that this error may succeed if retried without modification after - * a delay indicated by `retry_after_seconds`. If false, callers should not - * retry the request without modification. - */ - bool may_retry = 2; - - /** - * If this error may be retried,, indicates the duration in seconds from the - * present after which the request may be retried without modification. This - * value has no meaning otherwise. - */ - uint64 retry_after_seconds = 3; -} - -enum CreateRegistrationSessionErrorType { - CREATE_REGISTRATION_SESSION_ERROR_TYPE_UNSPECIFIED = 0; - - /** - * Indicates that a session could not be created because too many requests to - * create a session for the given phone number have been received in some - * window of time. Callers should wait and try again later. - */ - CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED = 1; - - /** - * Indicates that the provided phone number could not be parsed. - */ - CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER = 2; -} - -message GetRegistrationSessionMetadataRequest { - /** - * The ID of the session for which to retrieve metadata. - */ - bytes session_id = 1; -} - -message GetRegistrationSessionMetadataResponse { - oneof response { - RegistrationSessionMetadata session_metadata = 1; - GetRegistrationSessionMetadataError error = 2; - } -} - -message GetRegistrationSessionMetadataError { - GetRegistrationSessionMetadataErrorType error_type = 1; -} - -enum GetRegistrationSessionMetadataErrorType { - GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_UNSPECIFIED = 0; - - /** - * No session was found with the given identifier. - */ - GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_NOT_FOUND = 1; -} - -message SendVerificationCodeRequest { - - reserved 1; - - /** - * The message transport to use to send a verification code to the destination - * phone number. - */ - MessageTransport transport = 2; - - /** - * A prioritized list of languages accepted by the destination; should be - * provided in the same format as the value of an HTTP Accept-Language header. - */ - string accept_language = 3; - - /** - * The type of client requesting a verification code. - */ - ClientType client_type = 4; - - /** - * The ID of a session within which to send (or re-send) a verification code. - */ - bytes session_id = 5; - - /** - * If provided, always attempt to use the specified sender to send - * this message. - */ - string sender_name = 6; -} - -enum MessageTransport { - MESSAGE_TRANSPORT_UNSPECIFIED = 0; - MESSAGE_TRANSPORT_SMS = 1; - MESSAGE_TRANSPORT_VOICE = 2; -} - -enum ClientType { - CLIENT_TYPE_UNSPECIFIED = 0; - CLIENT_TYPE_IOS = 1; - CLIENT_TYPE_ANDROID_WITH_FCM = 2; - CLIENT_TYPE_ANDROID_WITHOUT_FCM = 3; -} - -message SendVerificationCodeResponse { - /** - * An opaque sequence of bytes that uniquely identifies the registration - * session associated with this registration attempt. - */ - bytes session_id = 1; - - /** - * Metadata for the named session. May be absent if the session could not be - * found or has expired. - */ - RegistrationSessionMetadata session_metadata = 2; - - /** - * If a code could not be sent, explains the underlying error. Will be absent - * if a code was sent successfully. Note that both an error and session - * metadata may be present in the same response because the session metadata - * may include information helpful for resolving the underlying error (i.e. - * "next attempt" times). - */ - SendVerificationCodeError error = 3; -} - -message SendVerificationCodeError { - /** - * The type of error that prevented a verification code from being sent. - */ - SendVerificationCodeErrorType error_type = 1; - - /** - * Indicates that this error may succeed if retried without modification after - * a delay indicated by `retry_after_seconds`. If false, callers should not - * retry the request without modification. - */ - bool may_retry = 2; - - /** - * If this error may be retried,, indicates the duration in seconds from the - * present after which the request may be retried without modification. This - * value has no meaning otherwise. - */ - uint64 retry_after_seconds = 3; -} - -enum SendVerificationCodeErrorType { - SEND_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED = 0; - - /** - * The sender received and understood the request to send a verification code, - * but declined to do so (i.e. due to rate limits or suspected fraud). - */ - SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_REJECTED = 1; - - /** - * The sender could not process or would not accept some part of a request - * (e.g. a valid phone number that cannot receive SMS messages). - */ - SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_ILLEGAL_ARGUMENT = 2; - - /** - * A verification could could not be sent via the requested channel due to - * timing/rate restrictions. The response object containing this error should - * include session metadata that indicates when the next attempt is allowed. - */ - SEND_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 3; - - /** - * No session was found with the given ID. - */ - SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 4; - - /** - * A new verification could could not be sent because the session has already - * been verified. - */ - SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_ALREADY_VERIFIED = 5; -} - -message CheckVerificationCodeRequest { - /** - * The session ID returned when sending a verification code. - */ - bytes session_id = 1; - - /** - * The client-provided verification code. - */ - string verification_code = 2; -} - -message CheckVerificationCodeResponse { - /** - * The outcome of the verification attempt; true if the verification code - * matched the expected code or false otherwise. - */ - bool verified = 1; - - /** - * Metadata for the named session. May be absent if the session could not be - * found or has expired. - */ - RegistrationSessionMetadata session_metadata = 2; - - /** - * If a code could not be checked, explains the underlying error. Will be - * absent if no error occurred. Note that both an error and session - * metadata may be present in the same response because the session metadata - * may include information helpful for resolving the underlying error (i.e. - * "next attempt" times). - */ - CheckVerificationCodeError error = 3; -} - -message CheckVerificationCodeError { - /** - * The type of error that prevented a verification code from being checked. - */ - CheckVerificationCodeErrorType error_type = 1; - - /** - * Indicates that this error may succeed if retried without modification after - * a delay indicated by `retry_after_seconds`. If false, callers should not - * retry the request without modification. - */ - bool may_retry = 2; - - /** - * If this error may be retried,, indicates the duration in seconds from the - * present after which the request may be retried without modification. This - * value has no meaning otherwise. - */ - uint64 retry_after_seconds = 3; -} - -enum CheckVerificationCodeErrorType { - CHECK_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED = 0; - - /** - * The caller has made too many incorrect guesses within the scope of this - * session and may not make any further guesses. - */ - CHECK_VERIFICATION_CODE_ERROR_TYPE_ATTEMPTS_EXHAUSTED = 1; - - /** - * The caller has attempted to submit a verification code even though no - * verification codes have been sent within the scope of this session. The - * caller must issue a "send code" request before trying again. - */ - CHECK_VERIFICATION_CODE_ERROR_TYPE_NO_CODE_SENT = 2; - - /** - * The caller has made too many guesses within some period of time. Callers - * should wait for the duration prescribed in the session metadata object - * elsewhere in the response before trying again. - */ - CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 3; - - /** - * The session identified in this request could not be found (possibly due to - * session expiration). - */ - CHECK_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 4; -} diff --git a/service/src/main/proto/TextSecure.proto b/service/src/main/proto/TextSecure.proto deleted file mode 100644 index ef340fb1b..000000000 --- a/service/src/main/proto/TextSecure.proto +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -syntax = "proto2"; - -package textsecure; - -option java_package = "org.whispersystems.textsecuregcm.entities"; -option java_outer_classname = "MessageProtos"; - -message Envelope { - enum Type { - UNKNOWN = 0; - CIPHERTEXT = 1; - KEY_EXCHANGE = 2; - PREKEY_BUNDLE = 3; - SERVER_DELIVERY_RECEIPT = 5; - UNIDENTIFIED_SENDER = 6; - reserved 7; - PLAINTEXT_CONTENT = 8; // for decryption error receipts - } - - optional Type type = 1; - optional string source_uuid = 11; - optional uint32 source_device = 7; - optional uint64 timestamp = 5; - optional bytes content = 8; // Contains an encrypted Content - optional string server_guid = 9; - optional uint64 server_timestamp = 10; - optional bool ephemeral = 12; // indicates that the message should not be persisted if the recipient is offline - optional string destination_uuid = 13; - optional bool urgent = 14 [default=true]; - optional string updated_pni = 15; - optional bool story = 16; // indicates that the content is a story. - optional bytes report_spam_token = 17; // token sent when reporting spam - // next: 18 -} - -message ProvisioningUuid { - optional string uuid = 1; -} - -message ServerCertificate { - message Certificate { - optional uint32 id = 1; - optional bytes key = 2; - } - - optional bytes certificate = 1; - optional bytes signature = 2; -} - -message SenderCertificate { - message Certificate { - optional string sender = 1; - optional string sender_uuid = 6; - optional uint32 sender_device = 2; - optional fixed64 expires = 3; - optional bytes identity_key = 4; - optional ServerCertificate signer = 5; - } - - optional bytes certificate = 1; - optional bytes signature = 2; -} diff --git a/service/src/main/resources/META-INF/services/io.dropwizard.logging.AppenderFactory b/service/src/main/resources/META-INF/services/io.dropwizard.logging.AppenderFactory deleted file mode 100644 index ebe62c359..000000000 --- a/service/src/main/resources/META-INF/services/io.dropwizard.logging.AppenderFactory +++ /dev/null @@ -1 +0,0 @@ -org.whispersystems.textsecuregcm.metrics.LogstashTcpSocketAppenderFactory diff --git a/service/src/main/resources/META-INF/services/io.dropwizard.logging.filter.FilterFactory b/service/src/main/resources/META-INF/services/io.dropwizard.logging.filter.FilterFactory deleted file mode 100644 index 5e33b192a..000000000 --- a/service/src/main/resources/META-INF/services/io.dropwizard.logging.filter.FilterFactory +++ /dev/null @@ -1 +0,0 @@ -org.whispersystems.textsecuregcm.util.logging.RequestLogEnabledFilterFactory diff --git a/service/src/main/resources/META-INF/services/io.dropwizard.metrics.ReporterFactory b/service/src/main/resources/META-INF/services/io.dropwizard.metrics.ReporterFactory deleted file mode 100644 index c6b835054..000000000 --- a/service/src/main/resources/META-INF/services/io.dropwizard.metrics.ReporterFactory +++ /dev/null @@ -1 +0,0 @@ -org.whispersystems.textsecuregcm.metrics.SignalDatadogReporterFactory diff --git a/service/src/main/resources/banner.txt b/service/src/main/resources/banner.txt deleted file mode 100644 index 6c26fda70..000000000 --- a/service/src/main/resources/banner.txt +++ /dev/null @@ -1,9 +0,0 @@ - _____ _ _ _____ -/ ___|(_) | |/ ___| -\ `--. _ __ _ _ __ __ _ | |\ `--. ___ _ __ __ __ ___ _ __ - `--. \| | / _` || '_ \ / _` || | `--. \ / _ \| '__|\ \ / // _ \| '__| -/\__/ /| || (_| || | | || (_| || |/\__/ /| __/| | \ V /| __/| | -\____/ |_| \__, ||_| |_| \__,_||_|\____/ \___||_| \_/ \___||_| - __/ | - |___/ - diff --git a/service/src/main/resources/lua/account_database_crawler/unlock.lua b/service/src/main/resources/lua/account_database_crawler/unlock.lua deleted file mode 100644 index b95d15d66..000000000 --- a/service/src/main/resources/lua/account_database_crawler/unlock.lua +++ /dev/null @@ -1,8 +0,0 @@ --- keys: lock_key --- argv: lock_value - -if redis.call("GET", KEYS[1]) == ARGV[1] then - return redis.call("DEL", KEYS[1]) -else - return 0 -end diff --git a/service/src/main/resources/lua/apn/get.lua b/service/src/main/resources/lua/apn/get.lua deleted file mode 100644 index 404a21b19..000000000 --- a/service/src/main/resources/lua/apn/get.lua +++ /dev/null @@ -1,70 +0,0 @@ -local pendingNotificationQueue = KEYS[1] - -local maxTime = ARGV[1] -local limit = ARGV[2] - -local hgetall = function (key) - local bulk = redis.call('HGETALL', key) - local result = {} - local nextkey - for i, v in ipairs(bulk) do - if i % 2 == 1 then - nextkey = v - else - result[nextkey] = v - end - end - return result -end - -local getNextInterval = function(interval) - if interval < 20000 then - return 20000 - end - - if interval < 40000 then - return 40000 - end - - if interval < 80000 then - return 80000 - end - - if interval < 160000 then - return 160000 - end - - if interval < 600000 then - return 600000 - end - - if interval < 1800000 then - return 1800000 - end - - return 3600000 -end - - -local results = redis.call("ZRANGEBYSCORE", pendingNotificationQueue, 0, maxTime, "LIMIT", 0, limit) -local collated = {} - -if results and next(results) then - for i, name in ipairs(results) do - local pending = hgetall(name) - local lastInterval = pending["interval"] - - if lastInterval == nil then - lastInterval = 0 - end - - local nextInterval = getNextInterval(tonumber(lastInterval)) - - redis.call("HSET", name, "interval", nextInterval) - redis.call("ZADD", pendingNotificationQueue, tonumber(maxTime) + nextInterval, name) - - collated[i] = pending["account"] .. ":" .. pending["device"] - end -end - -return collated diff --git a/service/src/main/resources/lua/apn/insert.lua b/service/src/main/resources/lua/apn/insert.lua deleted file mode 100644 index 3512f22a3..000000000 --- a/service/src/main/resources/lua/apn/insert.lua +++ /dev/null @@ -1,14 +0,0 @@ -local pendingNotificationQueue = KEYS[1] -local endpoint = KEYS[2] - -local timestamp = ARGV[1] -local interval = ARGV[2] -local account = ARGV[3] -local deviceId = ARGV[4] - -redis.call("HSET", endpoint, "created", timestamp) -redis.call("HSET", endpoint, "interval", interval) -redis.call("HSET", endpoint, "account", account) -redis.call("HSET", endpoint, "device", deviceId) - -redis.call("ZADD", pendingNotificationQueue, timestamp, endpoint) diff --git a/service/src/main/resources/lua/apn/remove.lua b/service/src/main/resources/lua/apn/remove.lua deleted file mode 100644 index c2fb84e42..000000000 --- a/service/src/main/resources/lua/apn/remove.lua +++ /dev/null @@ -1,5 +0,0 @@ -local pendingNotificationQueue = KEYS[1] -local endpoint = KEYS[2] - -redis.call("DEL", endpoint) -return redis.call("ZREM", pendingNotificationQueue, endpoint) diff --git a/service/src/main/resources/lua/apn/schedule_background_notification.lua b/service/src/main/resources/lua/apn/schedule_background_notification.lua deleted file mode 100644 index cab867819..000000000 --- a/service/src/main/resources/lua/apn/schedule_background_notification.lua +++ /dev/null @@ -1,17 +0,0 @@ -local lastBackgroundNotificationTimestampKey = KEYS[1] -local queueKey = KEYS[2] - -local accountDevicePair = ARGV[1] -local currentTimeMillis = tonumber(ARGV[2]) -local backgroundNotificationPeriod = tonumber(ARGV[3]) - -local lastBackgroundNotificationTimestamp = redis.call("GET", lastBackgroundNotificationTimestampKey) -local nextNotificationTimestamp - -if (lastBackgroundNotificationTimestamp) then - nextNotificationTimestamp = tonumber(lastBackgroundNotificationTimestamp) + backgroundNotificationPeriod -else - nextNotificationTimestamp = currentTimeMillis -end - -redis.call("ZADD", queueKey, "NX", nextNotificationTimestamp, accountDevicePair) diff --git a/service/src/main/resources/lua/clear_presence.lua b/service/src/main/resources/lua/clear_presence.lua deleted file mode 100644 index 9e716c118..000000000 --- a/service/src/main/resources/lua/clear_presence.lua +++ /dev/null @@ -1,9 +0,0 @@ -local presenceKey = KEYS[1] -local presenceUuid = ARGV[1] - -if redis.call("GET", presenceKey) == presenceUuid then - redis.call("DEL", presenceKey) - return true -end - -return false diff --git a/service/src/main/resources/lua/get_items.lua b/service/src/main/resources/lua/get_items.lua deleted file mode 100644 index 045a804ac..000000000 --- a/service/src/main/resources/lua/get_items.lua +++ /dev/null @@ -1,25 +0,0 @@ -local queueKey = KEYS[1] -local queueLockKey = KEYS[2] -local limit = ARGV[1] -local afterMessageId = ARGV[2] - -local locked = redis.call("GET", queueLockKey) - -if locked then - return {} -end - -if afterMessageId == "null" then - -- An index range is inclusive - local min = 0 - local max = limit - 1 - - if max < 0 then - return {} - end - - return redis.call("ZRANGE", queueKey, min, max, "WITHSCORES") -else - -- note: this is deprecated in Redis 6.2, and should be migrated to zrange after the cluster is updated - return redis.call("ZRANGEBYSCORE", queueKey, "("..afterMessageId, "+inf", "WITHSCORES", "LIMIT", 0, limit) -end diff --git a/service/src/main/resources/lua/get_queues_to_persist.lua b/service/src/main/resources/lua/get_queues_to_persist.lua deleted file mode 100644 index 97f9793ab..000000000 --- a/service/src/main/resources/lua/get_queues_to_persist.lua +++ /dev/null @@ -1,11 +0,0 @@ -local queueTotalIndexKey = KEYS[1] -local maxTime = ARGV[1] -local limit = ARGV[2] - -local results = redis.call("ZRANGEBYSCORE", queueTotalIndexKey, 0, maxTime, "LIMIT", 0, limit) - -if results and next(results) then - redis.call("ZREM", queueTotalIndexKey, unpack(results)) -end - -return results diff --git a/service/src/main/resources/lua/insert_item.lua b/service/src/main/resources/lua/insert_item.lua deleted file mode 100644 index 76eea91bf..000000000 --- a/service/src/main/resources/lua/insert_item.lua +++ /dev/null @@ -1,22 +0,0 @@ -local queueKey = KEYS[1] -local queueMetadataKey = KEYS[2] -local queueTotalIndexKey = KEYS[3] -local message = ARGV[1] -local currentTime = ARGV[2] -local guid = ARGV[3] - -if redis.call("HEXISTS", queueMetadataKey, guid) == 1 then - return tonumber(redis.call("HGET", queueMetadataKey, guid)) -end - -local messageId = redis.call("HINCRBY", queueMetadataKey, "counter", 1) - -redis.call("ZADD", queueKey, "NX", messageId, message) - -redis.call("HSET", queueMetadataKey, guid, messageId) - -redis.call("EXPIRE", queueKey, 7776000) -- 90 days -redis.call("EXPIRE", queueMetadataKey, 7776000) -- 90 days - -redis.call("ZADD", queueTotalIndexKey, "NX", currentTime, queueKey) -return messageId diff --git a/service/src/main/resources/lua/periodic_worker/unlock.lua b/service/src/main/resources/lua/periodic_worker/unlock.lua deleted file mode 100644 index b95d15d66..000000000 --- a/service/src/main/resources/lua/periodic_worker/unlock.lua +++ /dev/null @@ -1,8 +0,0 @@ --- keys: lock_key --- argv: lock_value - -if redis.call("GET", KEYS[1]) == ARGV[1] then - return redis.call("DEL", KEYS[1]) -else - return 0 -end diff --git a/service/src/main/resources/lua/remove_item_by_guid.lua b/service/src/main/resources/lua/remove_item_by_guid.lua deleted file mode 100644 index b80d32f96..000000000 --- a/service/src/main/resources/lua/remove_item_by_guid.lua +++ /dev/null @@ -1,28 +0,0 @@ -local queueKey = KEYS[1] -local queueMetadataKey = KEYS[2] -local queueTotalIndexKey = KEYS[3] - -local removedMessages = {} - -for _, guid in ipairs(ARGV) do - local messageId = redis.call("HGET", queueMetadataKey, guid) - - if messageId then - local envelope = redis.call("ZRANGEBYSCORE", queueKey, messageId, messageId, "LIMIT", 0, 1) - - redis.call("ZREMRANGEBYSCORE", queueKey, messageId, messageId) - redis.call("HDEL", queueMetadataKey, guid) - - if envelope and next(envelope) then - removedMessages[#removedMessages + 1] = envelope[1] - end - end -end - -if (redis.call("ZCARD", queueKey) == 0) then - redis.call("DEL", queueKey) - redis.call("DEL", queueMetadataKey) - redis.call("ZREM", queueTotalIndexKey, queueKey) -end - -return removedMessages diff --git a/service/src/main/resources/lua/remove_queue.lua b/service/src/main/resources/lua/remove_queue.lua deleted file mode 100644 index ace767eb5..000000000 --- a/service/src/main/resources/lua/remove_queue.lua +++ /dev/null @@ -1,7 +0,0 @@ -local queueKey = KEYS[1] -local queueMetadataKey = KEYS[2] -local queueTotalIndexKey = KEYS[3] - -redis.call("DEL", queueKey) -redis.call("DEL", queueMetadataKey) -redis.call("ZREM", queueTotalIndexKey, queueKey) diff --git a/service/src/main/resources/lua/renew_presence.lua b/service/src/main/resources/lua/renew_presence.lua deleted file mode 100644 index 71b47c869..000000000 --- a/service/src/main/resources/lua/renew_presence.lua +++ /dev/null @@ -1,7 +0,0 @@ -local presenceKey = KEYS[1] -local presenceUuid = ARGV[1] -local expireSeconds = ARGV[2] - -if redis.call("GET", presenceKey) == presenceUuid then - redis.call("EXPIRE", presenceKey, expireSeconds) -end diff --git a/service/src/main/resources/org/signal/badges/Badges.properties b/service/src/main/resources/org/signal/badges/Badges.properties deleted file mode 100644 index fa1c048ca..000000000 --- a/service/src/main/resources/org/signal/badges/Badges.properties +++ /dev/null @@ -1,31 +0,0 @@ -# -# Copyright 2021 Signal Messenger, LLC -# SPDX-License-Identifier: AGPL-3.0-only -# - -TEST_name = Test Badge -TEST_description = {short_name} has this badge for testing purposes. - -TEST1_name = Test Badge Alpha -TEST1_description = {short_name} is testing the alpha test badge. - -TEST2_name = Test Badge Beta -TEST2_description = {short_name} is testing the beta test badge. - -TEST3_name = Test Badge Gamma -TEST3_description = {short_name} is testing the gamma test badge. - -R_LOW_name = Signal Star -R_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. - -R_MED_name = Signal Planet -R_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. - -R_HIGH_name = Signal Sun -R_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. - -BOOST_name = Signal Boost -BOOST_description = {short_name} supported Signal with a donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you. - -GIFT_name = Signal UFO -GIFT_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. diff --git a/service/src/main/resources/org/signal/badges/Badges_en.properties b/service/src/main/resources/org/signal/badges/Badges_en.properties deleted file mode 100644 index e69de29bb..000000000 diff --git a/service/src/main/resources/org/signal/subscriptions/Subscriptions.properties b/service/src/main/resources/org/signal/subscriptions/Subscriptions.properties deleted file mode 100644 index ed041b066..000000000 --- a/service/src/main/resources/org/signal/subscriptions/Subscriptions.properties +++ /dev/null @@ -1,13 +0,0 @@ -# -# Copyright 2021 Signal Messenger, LLC -# SPDX-License-Identifier: AGPL-3.0-only -# -# These are deprecated, will be unused by clients in a future update, and can be removed in ~April 2023. -# First subscription level -R_LOW = Sustainer 1 - -# Second subscription level -R_MED = Sustainer 2 - -# Third subscription level -R_HIGH = Sustainer 3 diff --git a/service/src/main/resources/org/signal/subscriptions/Subscriptions_en.properties b/service/src/main/resources/org/signal/subscriptions/Subscriptions_en.properties deleted file mode 100644 index e69de29bb..000000000 diff --git a/service/src/main/resources/org/whispersystems/textsecuregcm/push/apns-certificates.pem b/service/src/main/resources/org/whispersystems/textsecuregcm/push/apns-certificates.pem deleted file mode 100644 index 66a721f5a..000000000 --- a/service/src/main/resources/org/whispersystems/textsecuregcm/push/apns-certificates.pem +++ /dev/null @@ -1,46 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb -MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow -GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj -YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL -MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE -BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM -GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua -BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe -3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4 -YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR -rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm -ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU -oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF -MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v -QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t -b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF -AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q -GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz -Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2 -G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi -l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3 -smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg== ------END CERTIFICATE----- - ------BEGIN CERTIFICATE----- -MIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT -MRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9i -YWwgQ0EwHhcNMDIwNTIxMDQwMDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQG -EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjEbMBkGA1UEAxMSR2VvVHJ1c3Qg -R2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2swYYzD9 -9BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjoBbdq -fnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDv -iS2Aelet8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU -1XupGc1V3sjs0l44U+VcT4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+ -bw8HHa8sHo9gOeL6NlMTOdReJivbPagUvTLrGAMoUgRx5aszPeE4uwc2hGKceeoW -MPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTA -ephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVkDBF9qn1l -uMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKIn -Z57QzxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfS -tQWVYrmm3ok9Nns4d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcF -PseKUgzbFbS9bZvlxrFUaKnjaZC2mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Un -hw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6pXE0zX5IJL4hmXXeXxx12E6nV -5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvmMw== ------END CERTIFICATE----- diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/CheckServiceConfigurations.java b/service/src/test/java/org/whispersystems/textsecuregcm/CheckServiceConfigurations.java deleted file mode 100644 index 08dac0247..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/CheckServiceConfigurations.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm; - -import java.io.File; -import java.util.Arrays; - -/** - * Checks whether all YAML configuration files in a given directory are valid. - *

- * Note: the current implementation fails fast, rather than reporting multiple invalid files - */ -public class CheckServiceConfigurations { - - private void checkConfiguration(final File configDirectory) { - - final File[] configFiles = configDirectory.listFiles(f -> - !f.isDirectory() - && f.getPath().endsWith(".yml")); - - if (configFiles == null || configFiles.length == 0) { - throw new IllegalArgumentException("No .yml configuration files found at " + configDirectory.getPath()); - } - - for (File configFile : configFiles) { - String[] args = new String[]{"check", configFile.getAbsolutePath()}; - - try { - new WhisperServerService().run(args); - } catch (final Exception e) { - // Invalid configuration will cause the "check" command to call `System.exit()`, rather than throwing, - // so this is unexpected - throw new RuntimeException(e); - } - } - } - - public static void main(String[] args) { - - if (args.length != 1) { - throw new IllegalArgumentException("Expected single argument with config directory: " + Arrays.toString(args)); - } - - final File configDirectory = new File(args[0]); - - if (!(configDirectory.exists() && configDirectory.isDirectory())) { - throw new IllegalArgumentException("No directory found at " + configDirectory.getPath()); - } - - new CheckServiceConfigurations().checkConfiguration(configDirectory); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/AuthEnablementRefreshRequirementProviderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/AuthEnablementRefreshRequirementProviderTest.java deleted file mode 100644 index 68c6597b7..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/AuthEnablementRefreshRequirementProviderTest.java +++ /dev/null @@ -1,483 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.google.protobuf.InvalidProtocolBufferException; -import io.dropwizard.auth.Auth; -import io.dropwizard.auth.PolymorphicAuthDynamicFeature; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.auth.basic.BasicCredentialAuthFilter; -import io.dropwizard.jersey.DropwizardResourceConfig; -import io.dropwizard.jersey.jackson.JacksonMessageBodyProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.security.Principal; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.LongStream; -import java.util.stream.Stream; -import javax.ws.rs.DELETE; -import javax.ws.rs.GET; -import javax.ws.rs.POST; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.eclipse.jetty.websocket.api.RemoteEndpoint; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.UpgradeRequest; -import org.glassfish.jersey.server.ApplicationHandler; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.server.monitoring.ApplicationEventListener; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.tests.util.DevicesHelper; -import org.whispersystems.websocket.WebSocketResourceProvider; -import org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider; -import org.whispersystems.websocket.logging.WebsocketRequestLog; -import org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory; -import org.whispersystems.websocket.messages.protobuf.SubProtocol; -import org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider; - -@ExtendWith(DropwizardExtensionsSupport.class) -class AuthEnablementRefreshRequirementProviderTest { - - private final ApplicationEventListener applicationEventListener = mock(ApplicationEventListener.class); - - private final Account account = new Account(); - private final Device authenticatedDevice = DevicesHelper.createDevice(1L); - - private final Supplier> principalSupplier = () -> Optional.of( - new TestPrincipal("test", account, authenticatedDevice)); - - private final ResourceExtension resources = ResourceExtension.builder() - .addProvider( - new PolymorphicAuthDynamicFeature<>(ImmutableMap.of( - TestPrincipal.class, - new BasicCredentialAuthFilter.Builder() - .setAuthenticator(c -> principalSupplier.get()).buildAuthFilter()))) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(TestPrincipal.class))) - .addProvider(applicationEventListener) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new TestResource()) - .build(); - - private AccountsManager accountsManager; - private ClientPresenceManager clientPresenceManager; - - private AuthEnablementRefreshRequirementProvider provider; - - @BeforeEach - void setup() { - accountsManager = mock(AccountsManager.class); - clientPresenceManager = mock(ClientPresenceManager.class); - - provider = new AuthEnablementRefreshRequirementProvider(accountsManager); - - final WebsocketRefreshRequestEventListener listener = - new WebsocketRefreshRequestEventListener(clientPresenceManager, provider); - - when(applicationEventListener.onRequest(any())).thenReturn(listener); - - final UUID uuid = UUID.randomUUID(); - account.setUuid(uuid); - account.addDevice(authenticatedDevice); - LongStream.range(2, 4).forEach(deviceId -> account.addDevice(DevicesHelper.createDevice(deviceId))); - - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - - account.getDevices() - .forEach(device -> when(clientPresenceManager.isPresent(uuid, device.getId())).thenReturn(true)); - } - - @Test - void testBuildDevicesEnabled() { - - final long disabledDeviceId = 3L; - - final Account account = mock(Account.class); - - final List devices = new ArrayList<>(); - when(account.getDevices()).thenReturn(devices); - - LongStream.range(1, 5) - .forEach(id -> { - final Device device = mock(Device.class); - when(device.getId()).thenReturn(id); - when(device.isEnabled()).thenReturn(id != disabledDeviceId); - devices.add(device); - }); - - final Map devicesEnabled = AuthEnablementRefreshRequirementProvider.buildDevicesEnabledMap(account); - - assertEquals(4, devicesEnabled.size()); - - assertAll(devicesEnabled.entrySet().stream() - .map(deviceAndEnabled -> () -> { - if (deviceAndEnabled.getKey().equals(disabledDeviceId)) { - assertFalse(deviceAndEnabled.getValue()); - } else { - assertTrue(deviceAndEnabled.getValue()); - } - })); - } - - @ParameterizedTest - @MethodSource - void testDeviceEnabledChanged(final Map initialEnabled, final Map finalEnabled) { - assert initialEnabled.size() == finalEnabled.size(); - - assert account.getMasterDevice().orElseThrow().isEnabled(); - - initialEnabled.forEach((deviceId, enabled) -> - DevicesHelper.setEnabled(account.getDevice(deviceId).orElseThrow(), enabled)); - - final Response response = resources.getJerseyTest() - .target("/v1/test/account/devices/enabled") - .request() - .header("Authorization", - "Basic " + Base64.getEncoder().encodeToString("user:pass".getBytes(StandardCharsets.UTF_8))) - .post(Entity.entity(finalEnabled, MediaType.APPLICATION_JSON)); - - assertEquals(200, response.getStatus()); - - final boolean expectDisplacedPresence = !initialEnabled.equals(finalEnabled); - - assertAll( - initialEnabled.keySet().stream() - .map(deviceId -> () -> verify(clientPresenceManager, times(expectDisplacedPresence ? 1 : 0)) - .disconnectPresence(account.getUuid(), deviceId))); - - assertAll( - finalEnabled.keySet().stream() - .map(deviceId -> () -> verify(clientPresenceManager, times(expectDisplacedPresence ? 1 : 0)) - .disconnectPresence(account.getUuid(), deviceId))); - } - - static Stream testDeviceEnabledChanged() { - return Stream.of( - Arguments.of(Map.of(1L, false, 2L, false), Map.of(1L, true, 2L, false)), - Arguments.of(Map.of(2L, false, 3L, false), Map.of(2L, true, 3L, true)), - Arguments.of(Map.of(2L, true, 3L, true), Map.of(2L, false, 3L, false)), - Arguments.of(Map.of(2L, true, 3L, true), Map.of(2L, true, 3L, true)), - Arguments.of(Map.of(2L, false, 3L, true), Map.of(2L, true, 3L, true)), - Arguments.of(Map.of(2L, true, 3L, false), Map.of(2L, true, 3L, true)) - ); - } - - @Test - void testDeviceAdded() { - assert account.getMasterDevice().orElseThrow().isEnabled(); - - final int initialDeviceCount = account.getDevices().size(); - - final List addedDeviceNames = List.of("newDevice1", "newDevice2"); - final Response response = resources.getJerseyTest() - .target("/v1/test/account/devices") - .request() - .header("Authorization", - "Basic " + Base64.getEncoder().encodeToString("user:pass".getBytes(StandardCharsets.UTF_8))) - .put(Entity.entity(addedDeviceNames, MediaType.APPLICATION_JSON_PATCH_JSON)); - - assertEquals(200, response.getStatus()); - - assertEquals(initialDeviceCount + addedDeviceNames.size(), account.getDevices().size()); - - verify(clientPresenceManager).disconnectPresence(account.getUuid(), 1); - verify(clientPresenceManager).disconnectPresence(account.getUuid(), 2); - verify(clientPresenceManager).disconnectPresence(account.getUuid(), 3); - } - - @ParameterizedTest - @ValueSource(ints = {1, 2}) - void testDeviceRemoved(final int removedDeviceCount) { - assert account.getMasterDevice().orElseThrow().isEnabled(); - - final List initialDeviceIds = account.getDevices().stream().map(Device::getId).collect(Collectors.toList()); - - final List deletedDeviceIds = account.getDevices().stream() - .map(Device::getId) - .filter(deviceId -> deviceId != 1L) - .limit(removedDeviceCount) - .collect(Collectors.toList()); - - assert deletedDeviceIds.size() == removedDeviceCount; - - final String deletedDeviceIdsParam = deletedDeviceIds.stream().map(String::valueOf) - .collect(Collectors.joining(",")); - - final Response response = resources.getJerseyTest() - .target("/v1/test/account/devices/" + deletedDeviceIdsParam) - .request() - .header("Authorization", - "Basic " + Base64.getEncoder().encodeToString("user:pass".getBytes(StandardCharsets.UTF_8))) - .delete(); - - assertEquals(200, response.getStatus()); - - initialDeviceIds.forEach(deviceId -> - verify(clientPresenceManager).disconnectPresence(account.getUuid(), deviceId)); - - verifyNoMoreInteractions(clientPresenceManager); - } - - @Test - void testMasterDeviceDisabledAndDeviceRemoved() { - assert account.getMasterDevice().orElseThrow().isEnabled(); - - final Set initialDeviceIds = account.getDevices().stream().map(Device::getId).collect(Collectors.toSet()); - - final long deletedDeviceId = 2L; - assertTrue(initialDeviceIds.remove(deletedDeviceId)); - - final Response response = resources.getJerseyTest() - .target("/v1/test/account/disableMasterDeviceAndDeleteDevice/" + deletedDeviceId) - .request() - .header("Authorization", - "Basic " + Base64.getEncoder().encodeToString("user:pass".getBytes(StandardCharsets.UTF_8))) - .post(Entity.entity("", MediaType.TEXT_PLAIN)); - - assertEquals(200, response.getStatus()); - - assertTrue(account.getDevice(deletedDeviceId).isEmpty()); - - initialDeviceIds.forEach(deviceId -> verify(clientPresenceManager).disconnectPresence(account.getUuid(), deviceId)); - verify(clientPresenceManager).disconnectPresence(account.getUuid(), deletedDeviceId); - - verifyNoMoreInteractions(clientPresenceManager); - } - - @Test - void testOnEvent() { - Response response = resources.getJerseyTest() - .target("/v1/test/hello") - .request() - // no authorization required - .get(); - - assertEquals(200, response.getStatus()); - - response = resources.getJerseyTest() - .target("/v1/test/authorized") - .request() - .header("Authorization", - "Basic " + Base64.getEncoder().encodeToString("user:pass".getBytes(StandardCharsets.UTF_8))) - .get(); - - assertEquals(200, response.getStatus()); - - verify(accountsManager, never()).getByAccountIdentifier(any(UUID.class)); - } - - @Nested - class WebSocket { - - private WebSocketResourceProvider provider; - private RemoteEndpoint remoteEndpoint; - - @BeforeEach - void setup() { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(applicationEventListener); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - - provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("test", account, authenticatedDevice), new ProtobufWebSocketMessageFactory(), - Optional.empty(), 30000); - - remoteEndpoint = mock(RemoteEndpoint.class); - Session session = mock(Session.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getRemote()).thenReturn(remoteEndpoint); - when(session.getUpgradeRequest()).thenReturn(request); - - provider.onWebSocketConnect(session); - } - - @Test - void testOnEvent() throws Exception { - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/hello", - new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - final SubProtocol.WebSocketResponseMessage response = verifyAndGetResponse(remoteEndpoint); - - assertEquals(200, response.getStatus()); - } - - private SubProtocol.WebSocketResponseMessage verifyAndGetResponse(final RemoteEndpoint remoteEndpoint) - throws InvalidProtocolBufferException { - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - return SubProtocol.WebSocketMessage.parseFrom(responseBytesCaptor.getValue().array()).getResponse(); - } - } - - public static class TestPrincipal implements Principal, AccountAndAuthenticatedDeviceHolder { - - private final String name; - private final Account account; - private final Device device; - - private TestPrincipal(String name, final Account account, final Device device) { - this.name = name; - this.account = account; - this.device = device; - } - - @Override - public String getName() { - return name; - } - - @Override - public Account getAccount() { - return account; - } - - @Override - public Device getAuthenticatedDevice() { - return device; - } - } - - @Path("/v1/test") - public static class TestResource { - - @GET - @Path("/hello") - public String testGetHello() { - return "Hello!"; - } - - @GET - @Path("/authorized") - public String testAuth(@Auth TestPrincipal principal) { - return "You’re in!"; - } - - @PUT - @Path("/account/enabled/{enabled}") - @ChangesDeviceEnabledState - public String setAccountEnabled(@Auth TestPrincipal principal, @PathParam("enabled") final boolean enabled) { - - final Device device = principal.getAccount().getMasterDevice().orElseThrow(); - - DevicesHelper.setEnabled(device, enabled); - - assert device.isEnabled() == enabled; - - return String.format("Set account to %s", enabled); - } - - @POST - @Path("/account/devices/enabled") - @ChangesDeviceEnabledState - public String setEnabled(@Auth TestPrincipal principal, Map deviceIdsEnabled) { - - final StringBuilder response = new StringBuilder(); - - for (Entry deviceIdEnabled : deviceIdsEnabled.entrySet()) { - final Device device = principal.getAccount().getDevice(deviceIdEnabled.getKey()).orElseThrow(); - DevicesHelper.setEnabled(device, deviceIdEnabled.getValue()); - - response.append(String.format("Set device enabled %s", deviceIdEnabled)); - } - - return response.toString(); - } - - @PUT - @Path("/account/devices") - @ChangesDeviceEnabledState - public String addDevices(@Auth TestPrincipal auth, List deviceNames) { - - deviceNames.forEach(name -> { - final Device device = DevicesHelper.createDevice(auth.getAccount().getNextDeviceId()); - auth.getAccount().addDevice(device); - - device.setName(name); - }); - - return "Added devices " + deviceNames; - } - - @DELETE - @Path("/account/devices/{deviceIds}") - @ChangesDeviceEnabledState - public String removeDevices(@Auth TestPrincipal auth, @PathParam("deviceIds") String deviceIds) { - - Arrays.stream(deviceIds.split(",")) - .map(Long::valueOf) - .forEach(auth.getAccount()::removeDevice); - - return "Removed device(s) " + deviceIds; - } - - @POST - @Path("/account/disableMasterDeviceAndDeleteDevice/{deviceId}") - @ChangesDeviceEnabledState - public String disableMasterDeviceAndRemoveDevice(@Auth TestPrincipal auth, @PathParam("deviceId") long deviceId) { - - DevicesHelper.setEnabled(auth.getAccount().getMasterDevice().orElseThrow(), false); - - auth.getAccount().removeDevice(deviceId); - - return "Removed device " + deviceId; - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/BaseAccountAuthenticatorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/BaseAccountAuthenticatorTest.java deleted file mode 100644 index fb9a2e8c2..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/BaseAccountAuthenticatorTest.java +++ /dev/null @@ -1,393 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.dropwizard.auth.basic.BasicCredentials; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.junitpioneer.jupiter.cartesian.CartesianTest; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.TestClock; - -class BaseAccountAuthenticatorTest { - - private final long today = 1590451200000L; - private final long yesterday = today - 86_400_000L; - private final long oldTime = yesterday - 86_400_000L; - private final long currentTime = today + 68_000_000L; - - private AccountsManager accountsManager; - private BaseAccountAuthenticator baseAccountAuthenticator; - private TestClock clock; - private Account acct1; - private Account acct2; - private Account oldAccount; - - @BeforeEach - void setup() { - accountsManager = mock(AccountsManager.class); - clock = TestClock.now(); - baseAccountAuthenticator = new BaseAccountAuthenticator(accountsManager, clock); - - // We use static UUIDs here because the UUID affects the "date last seen" offset - acct1 = AccountsHelper.generateTestAccount("+14088675309", UUID.fromString("c139cb3e-f70c-4460-b221-815e8bdf778f"), UUID.randomUUID(), List.of(generateTestDevice(yesterday)), null); - acct2 = AccountsHelper.generateTestAccount("+14088675310", UUID.fromString("30018a41-2764-4bc7-a935-775dfef84ad1"), UUID.randomUUID(), List.of(generateTestDevice(yesterday)), null); - oldAccount = AccountsHelper.generateTestAccount("+14088675311", UUID.fromString("adfce52b-9299-4c25-9c51-412fb420c6a6"), UUID.randomUUID(), List.of(generateTestDevice(oldTime)), null); - - AccountsHelper.setupMockUpdate(accountsManager); - } - - private static Device generateTestDevice(final long lastSeen) { - final Device device = new Device(); - device.setId(Device.MASTER_ID); - device.setLastSeen(lastSeen); - - return device; - } - - @Test - void testUpdateLastSeenMiddleOfDay() { - clock.pin(Instant.ofEpochMilli(currentTime)); - - final Device device1 = acct1.getDevices().stream().findFirst().get(); - final Device device2 = acct2.getDevices().stream().findFirst().get(); - - final Account updatedAcct1 = baseAccountAuthenticator.updateLastSeen(acct1, device1); - final Account updatedAcct2 = baseAccountAuthenticator.updateLastSeen(acct2, device2); - - verify(accountsManager, never()).updateDeviceLastSeen(eq(acct1), any(), anyLong()); - verify(accountsManager).updateDeviceLastSeen(eq(acct2), eq(device2), anyLong()); - - assertThat(device1.getLastSeen()).isEqualTo(yesterday); - assertThat(device2.getLastSeen()).isEqualTo(today); - - assertThat(acct1).isSameAs(updatedAcct1); - assertThat(acct2).isNotSameAs(updatedAcct2); - } - - @Test - void testUpdateLastSeenStartOfDay() { - clock.pin(Instant.ofEpochMilli(today)); - - final Device device1 = acct1.getDevices().stream().findFirst().get(); - final Device device2 = acct2.getDevices().stream().findFirst().get(); - - final Account updatedAcct1 = baseAccountAuthenticator.updateLastSeen(acct1, device1); - final Account updatedAcct2 = baseAccountAuthenticator.updateLastSeen(acct2, device2); - - verify(accountsManager, never()).updateDeviceLastSeen(eq(acct1), any(), anyLong()); - verify(accountsManager, never()).updateDeviceLastSeen(eq(acct2), any(), anyLong()); - - assertThat(device1.getLastSeen()).isEqualTo(yesterday); - assertThat(device2.getLastSeen()).isEqualTo(yesterday); - - assertThat(acct1).isSameAs(updatedAcct1); - assertThat(acct2).isSameAs(updatedAcct2); - } - - @Test - void testUpdateLastSeenEndOfDay() { - clock.pin(Instant.ofEpochMilli(today + 86_400_000L - 1)); - - final Device device1 = acct1.getDevices().stream().findFirst().get(); - final Device device2 = acct2.getDevices().stream().findFirst().get(); - - final Account updatedAcct1 = baseAccountAuthenticator.updateLastSeen(acct1, device1); - final Account updatedAcct2 = baseAccountAuthenticator.updateLastSeen(acct2, device2); - - verify(accountsManager).updateDeviceLastSeen(eq(acct1), eq(device1), anyLong()); - verify(accountsManager).updateDeviceLastSeen(eq(acct2), eq(device2), anyLong()); - - assertThat(device1.getLastSeen()).isEqualTo(today); - assertThat(device2.getLastSeen()).isEqualTo(today); - - assertThat(updatedAcct1).isNotSameAs(acct1); - assertThat(updatedAcct2).isNotSameAs(acct2); - } - - @Test - void testNeverWriteYesterday() { - clock.pin(Instant.ofEpochMilli(today)); - - final Device device = oldAccount.getDevices().stream().findFirst().get(); - - baseAccountAuthenticator.updateLastSeen(oldAccount, device); - - verify(accountsManager).updateDeviceLastSeen(eq(oldAccount), eq(device), anyLong()); - - assertThat(device.getLastSeen()).isEqualTo(today); - } - - @Test - void testAuthenticate() { - final UUID uuid = UUID.randomUUID(); - final long deviceId = 1; - final String password = "12345"; - - final Account account = mock(Account.class); - final Device device = mock(Device.class); - final SaltedTokenHash credentials = mock(SaltedTokenHash.class); - - clock.unpin(); - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - when(account.getUuid()).thenReturn(uuid); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - when(account.isEnabled()).thenReturn(true); - when(device.getId()).thenReturn(deviceId); - when(device.isEnabled()).thenReturn(true); - when(device.getAuthTokenHash()).thenReturn(credentials); - when(credentials.verify(password)).thenReturn(true); - when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION); - - final Optional maybeAuthenticatedAccount = - baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), true); - - assertThat(maybeAuthenticatedAccount).isPresent(); - assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid); - assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device); - verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any());; - } - - @Test - void testAuthenticateNonDefaultDevice() { - final UUID uuid = UUID.randomUUID(); - final long deviceId = 2; - final String password = "12345"; - - final Account account = mock(Account.class); - final Device device = mock(Device.class); - final SaltedTokenHash credentials = mock(SaltedTokenHash.class); - - clock.unpin(); - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - when(account.getUuid()).thenReturn(uuid); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - when(account.isEnabled()).thenReturn(true); - when(device.getId()).thenReturn(deviceId); - when(device.isEnabled()).thenReturn(true); - when(device.getAuthTokenHash()).thenReturn(credentials); - when(credentials.verify(password)).thenReturn(true); - when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION); - - final Optional maybeAuthenticatedAccount = - baseAccountAuthenticator.authenticate(new BasicCredentials(uuid + "." + deviceId, password), true); - - assertThat(maybeAuthenticatedAccount).isPresent(); - assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid); - assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device); - verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any()); - } - - @CartesianTest - void testAuthenticateEnabledRequired( - @CartesianTest.Values(booleans = {true, false}) final boolean enabledRequired, - @CartesianTest.Values(booleans = {true, false}) final boolean accountEnabled, - @CartesianTest.Values(booleans = {true, false}) final boolean deviceEnabled, - @CartesianTest.Values(booleans = {true, false}) final boolean authenticatedDeviceIsPrimary) { - final UUID uuid = UUID.randomUUID(); - final long deviceId = authenticatedDeviceIsPrimary ? 1 : 2; - final String password = "12345"; - - final Account account = mock(Account.class); - final Device authenticatedDevice = mock(Device.class); - final SaltedTokenHash credentials = mock(SaltedTokenHash.class); - - clock.unpin(); - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - when(account.getUuid()).thenReturn(uuid); - when(account.getDevice(deviceId)).thenReturn(Optional.of(authenticatedDevice)); - when(account.isEnabled()).thenReturn(accountEnabled); - when(authenticatedDevice.getId()).thenReturn(deviceId); - when(authenticatedDevice.isEnabled()).thenReturn(deviceEnabled); - when(authenticatedDevice.getAuthTokenHash()).thenReturn(credentials); - when(credentials.verify(password)).thenReturn(true); - when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION); - - final String identifier; - if (authenticatedDeviceIsPrimary) { - identifier = uuid.toString(); - } else { - identifier = uuid.toString() + BaseAccountAuthenticator.DEVICE_ID_SEPARATOR + deviceId; - } - final Optional maybeAuthenticatedAccount = - baseAccountAuthenticator.authenticate(new BasicCredentials(identifier, password), enabledRequired); - - if (enabledRequired && !(accountEnabled && deviceEnabled)) { - assertThat(maybeAuthenticatedAccount).isEmpty(); - } else { - assertThat(maybeAuthenticatedAccount).isPresent(); - assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid); - assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(authenticatedDevice); - } - } - - @Test - void testAuthenticateV1() { - final UUID uuid = UUID.randomUUID(); - final long deviceId = 1; - final String password = "12345"; - - final Account account = mock(Account.class); - final Device device = mock(Device.class); - final SaltedTokenHash credentials = mock(SaltedTokenHash.class); - - clock.unpin(); - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - when(account.getUuid()).thenReturn(uuid); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - when(account.isEnabled()).thenReturn(true); - when(device.getId()).thenReturn(deviceId); - when(device.isEnabled()).thenReturn(true); - when(device.getAuthTokenHash()).thenReturn(credentials); - when(credentials.verify(password)).thenReturn(true); - when(credentials.getVersion()).thenReturn(SaltedTokenHash.Version.V1); - - final Optional maybeAuthenticatedAccount = - baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password), true); - - assertThat(maybeAuthenticatedAccount).isPresent(); - assertThat(maybeAuthenticatedAccount.get().getAccount().getUuid()).isEqualTo(uuid); - assertThat(maybeAuthenticatedAccount.get().getAuthenticatedDevice()).isEqualTo(device); - verify(accountsManager, times(1)).updateDeviceAuthentication( - any(), // this won't be 'account', because it'll already be updated by updateDeviceLastSeen - eq(device), any()); - } - @Test - void testAuthenticateAccountNotFound() { - assertThat(baseAccountAuthenticator.authenticate(new BasicCredentials(UUID.randomUUID().toString(), "password"), true)) - .isEmpty(); - } - - @Test - void testAuthenticateDeviceNotFound() { - final UUID uuid = UUID.randomUUID(); - final long deviceId = 1; - final String password = "12345"; - - final Account account = mock(Account.class); - final Device device = mock(Device.class); - final SaltedTokenHash credentials = mock(SaltedTokenHash.class); - - clock.unpin(); - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - when(account.getUuid()).thenReturn(uuid); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - when(account.isEnabled()).thenReturn(true); - when(device.getId()).thenReturn(deviceId); - when(device.isEnabled()).thenReturn(true); - when(device.getAuthTokenHash()).thenReturn(credentials); - when(credentials.verify(password)).thenReturn(true); - when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION); - - final Optional maybeAuthenticatedAccount = - baseAccountAuthenticator.authenticate(new BasicCredentials(uuid + "." + (deviceId + 1), password), true); - - assertThat(maybeAuthenticatedAccount).isEmpty(); - verify(account).getDevice(deviceId + 1); - } - - @Test - void testAuthenticateIncorrectPassword() { - final UUID uuid = UUID.randomUUID(); - final long deviceId = 1; - final String password = "12345"; - - final Account account = mock(Account.class); - final Device device = mock(Device.class); - final SaltedTokenHash credentials = mock(SaltedTokenHash.class); - - clock.unpin(); - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - when(account.getUuid()).thenReturn(uuid); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - when(account.isEnabled()).thenReturn(true); - when(device.getId()).thenReturn(deviceId); - when(device.isEnabled()).thenReturn(true); - when(device.getAuthTokenHash()).thenReturn(credentials); - when(credentials.verify(password)).thenReturn(true); - when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION); - - final String incorrectPassword = password + "incorrect"; - - final Optional maybeAuthenticatedAccount = - baseAccountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), incorrectPassword), true); - - assertThat(maybeAuthenticatedAccount).isEmpty(); - verify(credentials).verify(incorrectPassword); - } - - @ParameterizedTest - @MethodSource - void testAuthenticateMalformedCredentials(final String username) { - final Optional maybeAuthenticatedAccount = assertDoesNotThrow( - () -> baseAccountAuthenticator.authenticate(new BasicCredentials(username, "password"), true)); - - assertThat(maybeAuthenticatedAccount).isEmpty(); - verify(accountsManager, never()).getByAccountIdentifier(any(UUID.class)); - } - - private static Stream testAuthenticateMalformedCredentials() { - return Stream.of( - "", - ".4", - "This is definitely not a valid UUID", - UUID.randomUUID() + "."); - } - - @ParameterizedTest - @MethodSource - void testGetIdentifierAndDeviceId(final String username, final String expectedIdentifier, final long expectedDeviceId) { - final Pair identifierAndDeviceId = BaseAccountAuthenticator.getIdentifierAndDeviceId(username); - - assertEquals(expectedIdentifier, identifierAndDeviceId.first()); - assertEquals(expectedDeviceId, identifierAndDeviceId.second()); - } - - private static Stream testGetIdentifierAndDeviceId() { - return Stream.of( - Arguments.of("", "", Device.MASTER_ID), - Arguments.of("test", "test", Device.MASTER_ID), - Arguments.of("test.7", "test", 7)); - } - - @ParameterizedTest - @ValueSource(strings = { - ".", - ".....", - "test.7.8", - "test." - }) - void testGetIdentifierAndDeviceIdMalformed(final String malformedUsername) { - assertThrows(IllegalArgumentException.class, - () -> BaseAccountAuthenticator.getIdentifierAndDeviceId(malformedUsername)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeaderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeaderTest.java deleted file mode 100644 index ddee28320..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeaderTest.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.storage.Device; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; - -class BasicAuthorizationHeaderTest { - - @Test - void fromString() throws InvalidAuthorizationHeaderException { - { - final BasicAuthorizationHeader header = - BasicAuthorizationHeader.fromString("Basic YWxhZGRpbjpvcGVuc2VzYW1l"); - - assertEquals("aladdin", header.getUsername()); - assertEquals("opensesame", header.getPassword()); - assertEquals(Device.MASTER_ID, header.getDeviceId()); - } - - { - final BasicAuthorizationHeader header = BasicAuthorizationHeader.fromString("Basic " + - Base64.getEncoder().encodeToString("username.7:password".getBytes(StandardCharsets.UTF_8))); - - assertEquals("username", header.getUsername()); - assertEquals("password", header.getPassword()); - assertEquals(7, header.getDeviceId()); - } - } - - @ParameterizedTest - @MethodSource - void fromStringMalformed(final String header) { - assertThrows(InvalidAuthorizationHeaderException.class, - () -> BasicAuthorizationHeader.fromString(header)); - } - - private static Stream fromStringMalformed() { - return Stream.of( - null, - "", - " ", - "Obviously not a valid authorization header", - "Digest YWxhZGRpbjpvcGVuc2VzYW1l", - "Basic", - "Basic ", - "Basic &&&&&&", - "Basic " + Base64.getEncoder().encodeToString("".getBytes(StandardCharsets.UTF_8)), - "Basic " + Base64.getEncoder().encodeToString(":".getBytes(StandardCharsets.UTF_8)), - "Basic " + Base64.getEncoder().encodeToString("test".getBytes(StandardCharsets.UTF_8)), - "Basic " + Base64.getEncoder().encodeToString("test.".getBytes(StandardCharsets.UTF_8)), - "Basic " + Base64.getEncoder().encodeToString("test.:".getBytes(StandardCharsets.UTF_8)), - "Basic " + Base64.getEncoder().encodeToString("test.:password".getBytes(StandardCharsets.UTF_8)), - "Basic " + Base64.getEncoder().encodeToString(":password".getBytes(StandardCharsets.UTF_8))); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/CertificateGeneratorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/CertificateGeneratorTest.java deleted file mode 100644 index 62903a7e9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/CertificateGeneratorTest.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.security.InvalidKeyException; -import java.util.Base64; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.signal.libsignal.protocol.ecc.Curve; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; - -class CertificateGeneratorTest { - - private static final String SIGNING_CERTIFICATE = "CiUIDBIhBbTz4h1My+tt+vw+TVscgUe/DeHS0W02tPWAWbTO2xc3EkD+go4bJnU0AcnFfbOLKoiBfCzouZtDYMOVi69rE7r4U9cXREEqOkUmU2WJBjykAxWPCcSTmVTYHDw7hkSp/puG"; - private static final String SIGNING_KEY = "ABOxG29xrfq4E7IrW11Eg7+HBbtba9iiS0500YoBjn4="; - private static final String IDENTITY_KEY = "BcxxDU9FGMda70E7+Uvm7pnQcEdXQ64aJCpPUeRSfcFo"; - - @Test - void testCreateFor() throws IOException, InvalidKeyException { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - final CertificateGenerator certificateGenerator = new CertificateGenerator(Base64.getDecoder().decode(SIGNING_CERTIFICATE), Curve.decodePrivatePoint(Base64.getDecoder().decode(SIGNING_KEY)), 1); - - when(account.getIdentityKey()).thenReturn(IDENTITY_KEY); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(account.getNumber()).thenReturn("+18005551234"); - when(device.getId()).thenReturn(4L); - - assertTrue(certificateGenerator.createFor(account, device, true).length > 0); - assertTrue(certificateGenerator.createFor(account, device, false).length > 0); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/PhoneNumberChangeRefreshRequirementProviderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/PhoneNumberChangeRefreshRequirementProviderTest.java deleted file mode 100644 index 5e04da700..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/PhoneNumberChangeRefreshRequirementProviderTest.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import javax.annotation.Nullable; -import javax.ws.rs.core.SecurityContext; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Pair; - -class PhoneNumberChangeRefreshRequirementProviderTest { - - private PhoneNumberChangeRefreshRequirementProvider provider; - - private Account account; - private RequestEvent requestEvent; - private ContainerRequest request; - - private static final UUID ACCOUNT_UUID = UUID.randomUUID(); - private static final String NUMBER = "+18005551234"; - private static final String CHANGED_NUMBER = "+18005554321"; - - @BeforeEach - void setUp() { - provider = new PhoneNumberChangeRefreshRequirementProvider(); - - account = mock(Account.class); - final Device device = mock(Device.class); - - when(account.getUuid()).thenReturn(ACCOUNT_UUID); - when(account.getNumber()).thenReturn(NUMBER); - when(account.getDevices()).thenReturn(List.of(device)); - when(device.getId()).thenReturn(Device.MASTER_ID); - - request = mock(ContainerRequest.class); - - final Map requestProperties = new HashMap<>(); - - doAnswer(invocation -> { - requestProperties.put(invocation.getArgument(0, String.class), invocation.getArgument(1)); - return null; - }).when(request).setProperty(anyString(), any()); - - when(request.getProperty(anyString())).thenAnswer( - invocation -> requestProperties.get(invocation.getArgument(0, String.class))); - - requestEvent = mock(RequestEvent.class); - when(requestEvent.getContainerRequest()).thenReturn(request); - } - - @Test - void handleRequestNoChange() { - setAuthenticatedAccount(request, account); - - provider.handleRequestFiltered(requestEvent); - assertEquals(Collections.emptyList(), provider.handleRequestFinished(requestEvent)); - } - - @Test - void handleRequestNumberChange() { - setAuthenticatedAccount(request, account); - - provider.handleRequestFiltered(requestEvent); - when(account.getNumber()).thenReturn(CHANGED_NUMBER); - assertEquals(List.of(new Pair<>(ACCOUNT_UUID, Device.MASTER_ID)), provider.handleRequestFinished(requestEvent)); - } - - @Test - void handleRequestNoAuthenticatedAccount() { - final ContainerRequest request = mock(ContainerRequest.class); - setAuthenticatedAccount(request, null); - - when(requestEvent.getContainerRequest()).thenReturn(request); - - provider.handleRequestFiltered(requestEvent); - assertEquals(Collections.emptyList(), provider.handleRequestFinished(requestEvent)); - } - - private static void setAuthenticatedAccount(final ContainerRequest mockRequest, @Nullable final Account account) { - final SecurityContext securityContext = mock(SecurityContext.class); - - when(mockRequest.getSecurityContext()).thenReturn(securityContext); - - if (account != null) { - final AuthenticatedAccount authenticatedAccount = mock(AuthenticatedAccount.class); - - when(securityContext.getUserPrincipal()).thenReturn(authenticatedAccount); - when(authenticatedAccount.getAccount()).thenReturn(account); - } else { - when(securityContext.getUserPrincipal()).thenReturn(null); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockError.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockError.java deleted file mode 100644 index 5c3a00ab3..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockError.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -public enum RegistrationLockError { - MISMATCH(RegistrationLockVerificationManager.FAILURE_HTTP_STATUS), - RATE_LIMITED(413) // This will be changed to 429 in a future revision - ; - - private final int expectedStatus; - - RegistrationLockError(final int expectedStatus) { - this.expectedStatus = expectedStatus; - } - - public int getExpectedStatus() { - return expectedStatus; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java deleted file mode 100644 index 28d4a9257..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.UUID; -import java.util.function.Consumer; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.ws.rs.WebApplicationException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.util.Pair; - -class RegistrationLockVerificationManagerTest { - - private final AccountsManager accountsManager = mock(AccountsManager.class); - private final ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class); - private final ExternalServiceCredentialsGenerator backupServiceCredentialsGeneraor = mock( - ExternalServiceCredentialsGenerator.class); - private final RateLimiters rateLimiters = mock(RateLimiters.class); - private final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager( - accountsManager, clientPresenceManager, backupServiceCredentialsGeneraor, rateLimiters); - - private final RateLimiter pinLimiter = mock(RateLimiter.class); - - private Account account; - private StoredRegistrationLock existingRegistrationLock; - - @BeforeEach - void setUp() { - when(rateLimiters.getPinLimiter()).thenReturn(pinLimiter); - when(backupServiceCredentialsGeneraor.generateForUuid(any())) - .thenReturn(mock(ExternalServiceCredentials.class)); - - account = mock(Account.class); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(account.getNumber()).thenReturn("+18005551212"); - existingRegistrationLock = mock(StoredRegistrationLock.class); - when(account.getRegistrationLock()).thenReturn(existingRegistrationLock); - } - - @ParameterizedTest - @EnumSource - void testErrors(RegistrationLockError error) throws Exception { - - when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true); - - final String submittedRegistrationLock = "reglock"; - - final Pair, Consumer> exceptionType = switch (error) { - case MISMATCH -> { - when(existingRegistrationLock.verify(submittedRegistrationLock)).thenReturn(false); - yield new Pair<>(WebApplicationException.class, e -> { - if (e instanceof WebApplicationException wae) { - assertEquals(RegistrationLockVerificationManager.FAILURE_HTTP_STATUS, wae.getResponse().getStatus()); - } else { - fail("Exception was not of expected type"); - } - }); - } - case RATE_LIMITED -> { - when(existingRegistrationLock.verify(any())).thenReturn(true); - doThrow(RateLimitExceededException.class).when(pinLimiter).validate(anyString()); - yield new Pair<>(RateLimitExceededException.class, ignored -> { - }); - } - }; - - final Exception e = assertThrows(exceptionType.first(), () -> - registrationLockVerificationManager.verifyRegistrationLock(account, submittedRegistrationLock)); - - exceptionType.second().accept(e); - } - - @ParameterizedTest - @MethodSource - void testSuccess(final boolean requiresClientRegistrationLock, @Nullable final String submittedRegistrationLock) { - - when(existingRegistrationLock.requiresClientRegistrationLock()) - .thenReturn(requiresClientRegistrationLock); - when(existingRegistrationLock.verify(submittedRegistrationLock)).thenReturn(true); - - assertDoesNotThrow( - () -> registrationLockVerificationManager.verifyRegistrationLock(account, submittedRegistrationLock)); - } - - static Stream testSuccess() { - return Stream.of( - Arguments.of(false, null), - Arguments.of(true, null), - Arguments.of(false, "reglock"), - Arguments.of(true, "reglock") - ); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java deleted file mode 100644 index 091e70d81..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredVerificationCodeTest.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.auth; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class StoredVerificationCodeTest { - - @ParameterizedTest - @MethodSource - void isValid(final StoredVerificationCode storedVerificationCode, final String code, final boolean expectValid) { - assertEquals(expectValid, storedVerificationCode.isValid(code)); - } - - private static Stream isValid() { - return Stream.of( - Arguments.of( - new StoredVerificationCode("code", System.currentTimeMillis(), null, null), "code", true), - Arguments.of(new StoredVerificationCode("code", System.currentTimeMillis(), null, null), "incorrect", false), - Arguments.of(new StoredVerificationCode("", System.currentTimeMillis(), null, null), "", false) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/TurnTokenGeneratorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/TurnTokenGeneratorTest.java deleted file mode 100644 index 927e19696..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/TurnTokenGeneratorTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.whispersystems.textsecuregcm.auth; - -import com.fasterxml.jackson.core.JsonProcessingException; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -import java.util.Map; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class TurnTokenGeneratorTest { - - @Test - public void testAlwaysSelectFirst() throws JsonProcessingException { - final String configString = """ - captcha: - scoreFloor: 1.0 - turn: - secret: bloop - uriConfigs: - - uris: - - always1.org - - always2.org - - uris: - - never.org - weight: 0 - """; - DynamicConfiguration config = DynamicConfigurationManager - .parseConfiguration(configString, DynamicConfiguration.class) - .orElseThrow(); - - @SuppressWarnings("unchecked") - DynamicConfigurationManager mockDynamicConfigManager = mock( - DynamicConfigurationManager.class); - - when(mockDynamicConfigManager.getConfiguration()).thenReturn(config); - final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(mockDynamicConfigManager); - - final long COUNT = 1000; - - final Map urlCounts = Stream - .generate(() -> turnTokenGenerator.generate("")) - .limit(COUNT) - .flatMap(token -> token.getUrls().stream()) - .collect(Collectors.groupingBy(i -> i, Collectors.counting())); - - assertThat(urlCounts.get("always1.org")).isEqualTo(COUNT); - assertThat(urlCounts.get("always2.org")).isEqualTo(COUNT); - assertThat(urlCounts).doesNotContainKey("never.org"); - } - - @Test - public void testProbabilisticUrls() throws JsonProcessingException { - final String configString = """ - captcha: - scoreFloor: 1.0 - turn: - secret: bloop - uriConfigs: - - uris: - - always.org - - sometimes1.org - weight: 5 - - uris: - - always.org - - sometimes2.org - weight: 5 - """; - DynamicConfiguration config = DynamicConfigurationManager - .parseConfiguration(configString, DynamicConfiguration.class) - .orElseThrow(); - - @SuppressWarnings("unchecked") - DynamicConfigurationManager mockDynamicConfigManager = mock( - DynamicConfigurationManager.class); - - when(mockDynamicConfigManager.getConfiguration()).thenReturn(config); - final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(mockDynamicConfigManager); - - final long COUNT = 1000; - - final Map urlCounts = Stream - .generate(() -> turnTokenGenerator.generate("")) - .limit(COUNT) - .flatMap(token -> token.getUrls().stream()) - .collect(Collectors.groupingBy(i -> i, Collectors.counting())); - - assertThat(urlCounts.get("always.org")).isEqualTo(COUNT); - assertThat(urlCounts.get("sometimes1.org")).isGreaterThan(0); - assertThat(urlCounts.get("sometimes2.org")).isGreaterThan(0); - } - - @Test - public void testExplicitEnrollment() throws JsonProcessingException { - final String configString = """ - captcha: - scoreFloor: 1.0 - turn: - secret: bloop - uriConfigs: - - uris: - - enrolled.org - weight: 0 - enrolledNumbers: - - +15555555555 - - uris: - - unenrolled.org - weight: 1 - """; - DynamicConfiguration config = DynamicConfigurationManager - .parseConfiguration(configString, DynamicConfiguration.class) - .orElseThrow(); - - @SuppressWarnings("unchecked") - DynamicConfigurationManager mockDynamicConfigManager = mock( - DynamicConfigurationManager.class); - - when(mockDynamicConfigManager.getConfiguration()).thenReturn(config); - - final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(mockDynamicConfigManager); - TurnToken token = turnTokenGenerator.generate("+15555555555"); - assertThat(token.getUrls().get(0)).isEqualTo("enrolled.org"); - token = turnTokenGenerator.generate("+15555555556"); - assertThat(token.getUrls().get(0)).isEqualTo("unenrolled.org"); - - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java deleted file mode 100644 index 529d8c17a..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.badges; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNullPointerException; -import static org.junit.jupiter.params.provider.Arguments.arguments; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; - -import java.time.Clock; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.ListResourceBundle; -import java.util.Locale; -import java.util.Map; -import java.util.ResourceBundle; -import java.util.ResourceBundle.Control; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.ArgumentCaptor; -import org.signal.i18n.HeaderControlledResourceBundleLookup; -import org.signal.i18n.ResourceBundleFactory; -import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; -import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.entities.Badge; -import org.whispersystems.textsecuregcm.entities.BadgeSvg; -import org.whispersystems.textsecuregcm.entities.SelfBadge; -import org.whispersystems.textsecuregcm.storage.AccountBadge; -import org.whispersystems.textsecuregcm.util.TestClock; - -public class ConfiguredProfileBadgeConverterTest { - - private final Clock clock = TestClock.pinned(Instant.ofEpochSecond(42)); - private ResourceBundleFactory resourceBundleFactory; - private ResourceBundle resourceBundle; - - @BeforeEach - void beforeEach() { - resourceBundleFactory = mock(ResourceBundleFactory.class, (invocation) -> { - throw new UnsupportedOperationException(); - }); - } - - private static String idFor(int i) { - return "Badge-" + i; - } - - private static String nameFor(int i) { - return "TRANSLATED NAME " + i; - } - - private static String desriptionFor(int i) { - return "TRANSLATED DESCRIPTION " + i; - } - - private static BadgeConfiguration newBadge(int i) { - return new BadgeConfiguration( - idFor(i), "other", List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld"))); - } - - private BadgesConfiguration createBadges(int count) { - List badges = new ArrayList<>(count); - Object[][] objects = new Object[count * 2][2]; - for (int i = 0; i < count; i++) { - badges.add(newBadge(i)); - objects[(i * 2)] = new Object[]{idFor(i) + "_name", nameFor(i)}; - objects[(i * 2) + 1] = new Object[]{idFor(i) + "_description", desriptionFor(i)}; - } - resourceBundle = new ListResourceBundle() { - @Override - protected Object[][] getContents() { - return objects; - } - }; - return new BadgesConfiguration(badges, List.of(), Map.of()); - } - - private BadgeConfiguration getBadge(BadgesConfiguration badgesConfiguration, int i) { - return badgesConfiguration.getBadges().stream() - .filter(badgeConfiguration -> idFor(i).equals(badgeConfiguration.getId())) - .findFirst().orElse(null); - } - - private ArgumentCaptor setupResourceBundle(Locale expectedLocale) { - ArgumentCaptor controlArgumentCaptor = - ArgumentCaptor.forClass(ResourceBundle.Control.class); - doReturn(resourceBundle).when(resourceBundleFactory).createBundle( - eq(ConfiguredProfileBadgeConverter.BASE_NAME), eq(expectedLocale), controlArgumentCaptor.capture()); - return controlArgumentCaptor; - } - - @Test - void testConvertEmptyList() { - BadgesConfiguration badgesConfiguration = createBadges(1); - ConfiguredProfileBadgeConverter badgeConverter = new ConfiguredProfileBadgeConverter(clock, badgesConfiguration, - new HeaderControlledResourceBundleLookup(resourceBundleFactory)); - assertThat(badgeConverter.convert(List.of(Locale.getDefault()), List.of(), false)).isNotNull().isEmpty(); - } - - @ParameterizedTest - @MethodSource - void testNoLocales(String name, Instant expiration, boolean visible, boolean isSelf, Badge expectedBadge) { - BadgesConfiguration badgesConfiguration = createBadges(1); - ConfiguredProfileBadgeConverter badgeConverter = - new ConfiguredProfileBadgeConverter(clock, badgesConfiguration, - new HeaderControlledResourceBundleLookup(resourceBundleFactory)); - setupResourceBundle(Locale.getDefault()); - - if (expectedBadge != null) { - assertThat(badgeConverter.convert(List.of(), List.of(new AccountBadge(name, expiration, visible)), isSelf)) - .isNotNull() - .hasSize(1) - .containsOnly(expectedBadge); - } else { - assertThat(badgeConverter.convert(List.of(), List.of(new AccountBadge(name, expiration, visible)), isSelf)) - .isNotNull() - .isEmpty(); - } - } - - @SuppressWarnings("unused") - static Stream testNoLocales() { - Instant expired = Instant.ofEpochSecond(41); - Instant notExpired = Instant.ofEpochSecond(43); - return Stream.of( - arguments(idFor(0), expired, false, false, null), - arguments(idFor(0), notExpired, false, false, null), - arguments(idFor(0), expired, true, false, null), - arguments(idFor(0), notExpired, true, false, - new Badge(idFor(0), "other", nameFor(0), desriptionFor(0), List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))), - arguments(idFor(1), expired, false, false, null), - arguments(idFor(1), notExpired, false, false, null), - arguments(idFor(1), expired, true, false, null), - arguments(idFor(1), notExpired, true, false, null), - arguments(idFor(0), expired, false, true, null), - arguments(idFor(0), notExpired, false, true, - new SelfBadge(idFor(0), "other", nameFor(0), desriptionFor(0), List.of("l", "m", "h", "x", "xx", "xxx"), - "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")), - notExpired, false)), - arguments(idFor(0), expired, true, true, null), - arguments(idFor(0), notExpired, true, true, - new SelfBadge(idFor(0), "other", nameFor(0), desriptionFor(0), List.of("l", "m", "h", "x", "xx", "xxx"), - "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")), - notExpired, true)), - arguments(idFor(1), expired, false, true, null), - arguments(idFor(1), notExpired, false, true, null), - arguments(idFor(1), expired, true, true, null), - arguments(idFor(1), notExpired, true, true, null)); - } - - @Test - void testCustomControl() { - BadgesConfiguration badgesConfiguration = createBadges(1); - ConfiguredProfileBadgeConverter badgeConverter = - new ConfiguredProfileBadgeConverter(clock, badgesConfiguration, - new HeaderControlledResourceBundleLookup(resourceBundleFactory)); - - Locale defaultLocale = Locale.getDefault(); - Locale enGb = new Locale("en", "GB"); - Locale en = new Locale("en"); - Locale esUs = new Locale("es", "US"); - - ArgumentCaptor controlArgumentCaptor = setupResourceBundle(enGb); - badgeConverter.convert(List.of(enGb, en, esUs), - List.of(new AccountBadge(idFor(0), Instant.ofEpochSecond(43), true)), false); - Control control = controlArgumentCaptor.getValue(); - - assertThatNullPointerException().isThrownBy(() -> control.getFormats(null)); - assertThatNullPointerException().isThrownBy(() -> control.getFallbackLocale(null, enGb)); - assertThatNullPointerException().isThrownBy( - () -> control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, null)); - - assertThat(control.getFormats(ConfiguredProfileBadgeConverter.BASE_NAME)).isNotNull().hasSize(1).containsOnly( - Control.FORMAT_PROPERTIES.toArray(new String[0])); - - try { - // temporarily override for purpose of ensuring this test doesn't change based on system default locale - Locale.setDefault(new Locale("xx", "XX")); - - assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, enGb)).isEqualTo(en); - assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, en)).isEqualTo(esUs); - assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, esUs)).isEqualTo( - Locale.getDefault()); - assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, Locale.getDefault())).isNull(); - - // now test what happens if the system default locale is in the list - // this should always terminate at the system default locale since the development defined bundle should get - // returned at that point anyhow - badgeConverter.convert(List.of(enGb, Locale.getDefault(), en, esUs), - List.of(new AccountBadge(idFor(0), Instant.ofEpochSecond(43), true)), false); - Control control2 = controlArgumentCaptor.getValue(); - - assertThat(control2.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, enGb)).isEqualTo( - Locale.getDefault()); - assertThat(control2.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, Locale.getDefault())).isNull(); - } finally { - Locale.setDefault(defaultLocale); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java deleted file mode 100644 index 83d932d9f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.captcha; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.captcha.CaptchaChecker.SEPARATOR; - -import java.io.IOException; -import java.util.List; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.ws.rs.BadRequestException; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -public class CaptchaCheckerTest { - - private static final String SITE_KEY = "site-key"; - private static final String TOKEN = "some-token"; - private static final String PREFIX = "prefix"; - private static final String PREFIX_A = "prefix-a"; - private static final String PREFIX_B = "prefix-b"; - - static Stream parseInputToken() { - return Stream.of( - Arguments.of( - String.join(SEPARATOR, PREFIX, SITE_KEY, TOKEN), - TOKEN, - SITE_KEY, - null), - Arguments.of( - String.join(SEPARATOR, PREFIX, SITE_KEY, "an-action", TOKEN), - TOKEN, - SITE_KEY, - "an-action"), - Arguments.of( - String.join(SEPARATOR, PREFIX, SITE_KEY, "an-action", TOKEN, "something-else"), - TOKEN + SEPARATOR + "something-else", - SITE_KEY, - "an-action") - ); - } - - private static CaptchaClient mockClient(final String prefix) throws IOException { - final CaptchaClient captchaClient = mock(CaptchaClient.class); - when(captchaClient.scheme()).thenReturn(prefix); - when(captchaClient.verify(any(), any(), any(), any())).thenReturn(AssessmentResult.invalid()); - return captchaClient; - } - - - @ParameterizedTest - @MethodSource - void parseInputToken(final String input, final String expectedToken, final String siteKey, - @Nullable final String expectedAction) throws IOException { - final CaptchaClient captchaClient = mockClient(PREFIX); - new CaptchaChecker(List.of(captchaClient)).verify(input, null); - verify(captchaClient, times(1)).verify(eq(siteKey), eq(expectedAction), eq(expectedToken), any()); - } - - @ParameterizedTest - @MethodSource - void scoreString(float score, String expected) { - assertThat(AssessmentResult.scoreString(score)).isEqualTo(expected); - } - - - static Stream scoreString() { - return Stream.of( - Arguments.of(0.3f, "30"), - Arguments.of(0.0f, "0"), - Arguments.of(0.333f, "30"), - Arguments.of(0.29f, "30"), - Arguments.of(Float.NaN, "0") - ); - } - - @Test - public void choose() throws IOException { - String ainput = String.join(SEPARATOR, PREFIX_A, SITE_KEY, TOKEN); - String binput = String.join(SEPARATOR, PREFIX_B, SITE_KEY, TOKEN); - final CaptchaClient a = mockClient(PREFIX_A); - final CaptchaClient b = mockClient(PREFIX_B); - - new CaptchaChecker(List.of(a, b)).verify(ainput, null); - verify(a, times(1)).verify(any(), any(), any(), any()); - - new CaptchaChecker(List.of(a, b)).verify(binput, null); - verify(b, times(1)).verify(any(), any(), any(), any()); - } - - static Stream badToken() { - return Stream.of( - Arguments.of(String.join(SEPARATOR, "invalid", SITE_KEY, "action", TOKEN)), - Arguments.of(String.join(SEPARATOR, PREFIX, TOKEN)), - Arguments.of(String.join(SEPARATOR, SITE_KEY, PREFIX, "action", TOKEN)) - ); - } - - @ParameterizedTest - @MethodSource - public void badToken(final String input) throws IOException { - final CaptchaClient cc = mockClient(PREFIX); - assertThrows(BadRequestException.class, () -> new CaptchaChecker(List.of(cc)).verify(input, null)); - - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java deleted file mode 100644 index e293b2346..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/captcha/HCaptchaClientTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.whispersystems.textsecuregcm.captcha; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.math.BigDecimal; -import java.net.http.HttpClient; -import java.net.http.HttpResponse; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -public class HCaptchaClientTest { - - private static final String SITE_KEY = "site-key"; - private static final String TOKEN = "token"; - - - static Stream captchaProcessed() { - return Stream.of( - Arguments.of(true, 0.6f, true), - Arguments.of(false, 0.6f, false), - Arguments.of(true, 0.4f, false), - Arguments.of(false, 0.4f, false) - ); - } - - @ParameterizedTest - @MethodSource - public void captchaProcessed(final boolean success, final float score, final boolean expectedResult) - throws IOException, InterruptedException { - - final HttpClient client = mockResponder(200, String.format(""" - { - "success": %b, - "score": %f, - "score-reasons": ["great job doing this captcha"] - } - """, - success, 1 - score)); // hCaptcha scores are inverted compared to recaptcha scores. (low score is good) - - final AssessmentResult result = new HCaptchaClient("fake", client, mockConfig(true, 0.5)) - .verify(SITE_KEY, "whatever", TOKEN, null); - if (!success) { - assertThat(result).isEqualTo(AssessmentResult.invalid()); - } else { - assertThat(result) - .isEqualTo(new AssessmentResult(expectedResult, AssessmentResult.scoreString(score))); - } - } - - @Test - public void errorResponse() throws IOException, InterruptedException { - final HttpClient httpClient = mockResponder(503, ""); - final HCaptchaClient client = new HCaptchaClient("fake", httpClient, mockConfig(true, 0.5)); - assertThrows(IOException.class, () -> client.verify(SITE_KEY, "whatever", TOKEN, null)); - } - - @Test - public void invalidScore() throws IOException, InterruptedException { - final HttpClient httpClient = mockResponder(200, """ - {"success" : true, "score": 1.1} - """); - final HCaptchaClient client = new HCaptchaClient("fake", httpClient, mockConfig(true, 0.5)); - assertThat(client.verify(SITE_KEY, "whatever", TOKEN, null)).isEqualTo(AssessmentResult.invalid()); - } - - @Test - public void badBody() throws IOException, InterruptedException { - final HttpClient httpClient = mockResponder(200, """ - {"success" : true, - """); - final HCaptchaClient client = new HCaptchaClient("fake", httpClient, mockConfig(true, 0.5)); - assertThrows(IOException.class, () -> client.verify(SITE_KEY, "whatever", TOKEN, null)); - } - - @Test - public void disabled() throws IOException { - final HCaptchaClient hc = new HCaptchaClient("fake", null, mockConfig(false, 0.5)); - assertThat(hc.verify(SITE_KEY, null, TOKEN, null)).isEqualTo(AssessmentResult.invalid()); - } - - private static HttpClient mockResponder(final int statusCode, final String jsonBody) - throws IOException, InterruptedException { - HttpClient httpClient = mock(HttpClient.class); - @SuppressWarnings("unchecked") final HttpResponse httpResponse = mock(HttpResponse.class); - - when(httpResponse.body()).thenReturn(jsonBody); - when(httpResponse.statusCode()).thenReturn(statusCode); - - when(httpClient.send(any(), any())).thenReturn(httpResponse); - return httpClient; - } - - private static DynamicConfigurationManager mockConfig(boolean enabled, double scoreFloor) { - final DynamicCaptchaConfiguration config = new DynamicCaptchaConfiguration(); - config.setAllowHCaptcha(enabled); - config.setScoreFloor(BigDecimal.valueOf(scoreFloor)); - - @SuppressWarnings("unchecked") final DynamicConfigurationManager m = mock( - DynamicConfigurationManager.class); - final DynamicConfiguration d = mock(DynamicConfiguration.class); - when(m.getConfiguration()).thenReturn(d); - when(d.getCaptchaConfiguration()).thenReturn(config); - return m; - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java deleted file mode 100644 index 0ecfbae2e..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java +++ /dev/null @@ -1,436 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.configuration.dynamic; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.vdurmont.semver4j.Semver; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; - -class DynamicConfigurationTest { - - private static final String REQUIRED_CONFIG = """ - captcha: - scoreFloor: 1.0 - """; - - @Test - void testParseExperimentConfig() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertFalse(emptyConfig.getExperimentEnrollmentConfiguration("test").isPresent()); - } - - { - final String experimentConfigYaml = REQUIRED_CONFIG.concat(""" - experiments: - percentageOnly: - enrollmentPercentage: 12 - uuidsAndPercentage: - enrolledUuids: - - 717b1c09-ed0b-4120-bb0e-f4697534b8e1 - - 279f264c-56d7-4bbf-b9da-de718ff90903 - enrollmentPercentage: 77 - uuidsOnly: - enrolledUuids: - - 71618739-114c-4b1f-bb0d-6478a44eb600 - uuids-with-dash: - enrolledUuids: - - 71618739-114c-4b1f-bb0d-6478ffffffff - """); - - final DynamicConfiguration config = - DynamicConfigurationManager.parseConfiguration(experimentConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertFalse(config.getExperimentEnrollmentConfiguration("unconfigured").isPresent()); - - assertTrue(config.getExperimentEnrollmentConfiguration("percentageOnly").isPresent()); - assertEquals(12, config.getExperimentEnrollmentConfiguration("percentageOnly").get().getEnrollmentPercentage()); - assertEquals(Collections.emptySet(), - config.getExperimentEnrollmentConfiguration("percentageOnly").get().getEnrolledUuids()); - - assertTrue(config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").isPresent()); - assertEquals(77, - config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").get().getEnrollmentPercentage()); - assertEquals(Set.of(UUID.fromString("717b1c09-ed0b-4120-bb0e-f4697534b8e1"), - UUID.fromString("279f264c-56d7-4bbf-b9da-de718ff90903")), - config.getExperimentEnrollmentConfiguration("uuidsAndPercentage").get().getEnrolledUuids()); - - assertTrue(config.getExperimentEnrollmentConfiguration("uuidsOnly").isPresent()); - assertEquals(0, config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrollmentPercentage()); - assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478a44eb600")), - config.getExperimentEnrollmentConfiguration("uuidsOnly").get().getEnrolledUuids()); - - assertTrue(config.getExperimentEnrollmentConfiguration("uuids-with-dash").isPresent()); - assertEquals(0, config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrollmentPercentage()); - assertEquals(Set.of(UUID.fromString("71618739-114c-4b1f-bb0d-6478ffffffff")), - config.getExperimentEnrollmentConfiguration("uuids-with-dash").get().getEnrolledUuids()); - } - } - - @Test - void testParsePreRegistrationExperiments() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertFalse(emptyConfig.getPreRegistrationEnrollmentConfiguration("test").isPresent()); - } - - { - final String experimentConfigYaml = REQUIRED_CONFIG.concat(""" - preRegistrationExperiments: - percentageOnly: - enrollmentPercentage: 17 - e164sCountryCodesAndPercentage: - enrolledE164s: - - +120255551212 - - +3655323174 - excludedE164s: - - +120255551213 - - +3655323175 - enrollmentPercentage: 46 - excludedCountryCodes: - - 47 - includedCountryCodes: - - 56 - e164sAndExcludedCodes: - enrolledE164s: - - +120255551212 - excludedCountryCodes: - - 47 - """); - - final DynamicConfiguration config = - DynamicConfigurationManager.parseConfiguration(experimentConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertFalse(config.getPreRegistrationEnrollmentConfiguration("unconfigured").isPresent()); - - { - final Optional percentageOnly = config - .getPreRegistrationEnrollmentConfiguration("percentageOnly"); - assertTrue(percentageOnly.isPresent()); - assertEquals(17, - percentageOnly.get().getEnrollmentPercentage()); - assertEquals(Collections.emptySet(), - percentageOnly.get().getEnrolledE164s()); - assertEquals(Collections.emptySet(), - percentageOnly.get().getExcludedE164s()); - } - - { - final Optional e164sCountryCodesAndPercentage = config - .getPreRegistrationEnrollmentConfiguration("e164sCountryCodesAndPercentage"); - - assertTrue(e164sCountryCodesAndPercentage.isPresent()); - assertEquals(46, - e164sCountryCodesAndPercentage.get().getEnrollmentPercentage()); - assertEquals(Set.of("+120255551212", "+3655323174"), - e164sCountryCodesAndPercentage.get().getEnrolledE164s()); - assertEquals(Set.of("+120255551213", "+3655323175"), - e164sCountryCodesAndPercentage.get().getExcludedE164s()); - assertEquals(Set.of("47"), - e164sCountryCodesAndPercentage.get().getExcludedCountryCodes()); - assertEquals(Set.of("56"), - e164sCountryCodesAndPercentage.get().getIncludedCountryCodes()); - } - - { - final Optional e164sAndExcludedCodes = config - .getPreRegistrationEnrollmentConfiguration("e164sAndExcludedCodes"); - assertTrue(e164sAndExcludedCodes.isPresent()); - assertEquals(0, e164sAndExcludedCodes.get().getEnrollmentPercentage()); - assertEquals(Set.of("+120255551212"), - e164sAndExcludedCodes.get().getEnrolledE164s()); - assertTrue(e164sAndExcludedCodes.get().getExcludedE164s().isEmpty()); - assertEquals(Set.of("47"), - e164sAndExcludedCodes.get().getExcludedCountryCodes()); - } - } - } - - @Test - void testParseRemoteDeprecationConfig() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertNotNull(emptyConfig.getRemoteDeprecationConfiguration()); - } - - { - final String remoteDeprecationConfig = REQUIRED_CONFIG.concat(""" - remoteDeprecation: - minimumVersions: - IOS: 1.2.3 - ANDROID: 4.5.6 - versionsPendingDeprecation: - DESKTOP: 7.8.9 - blockedVersions: - DESKTOP: - - 1.4.0-beta.2 - """); - - final DynamicConfiguration config = - DynamicConfigurationManager.parseConfiguration(remoteDeprecationConfig, DynamicConfiguration.class).orElseThrow(); - - final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = config - .getRemoteDeprecationConfiguration(); - - assertEquals(Map.of(ClientPlatform.IOS, new Semver("1.2.3"), ClientPlatform.ANDROID, new Semver("4.5.6")), - remoteDeprecationConfiguration.getMinimumVersions()); - assertEquals(Map.of(ClientPlatform.DESKTOP, new Semver("7.8.9")), - remoteDeprecationConfiguration.getVersionsPendingDeprecation()); - assertEquals(Map.of(ClientPlatform.DESKTOP, Set.of(new Semver("1.4.0-beta.2"))), - remoteDeprecationConfiguration.getBlockedVersions()); - assertTrue(remoteDeprecationConfiguration.getVersionsPendingBlock().isEmpty()); - } - } - - @Test - void testParsePaymentsConfiguration() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertTrue(emptyConfig.getPaymentsConfiguration().getDisallowedPrefixes().isEmpty()); - } - - { - final String paymentsConfigYaml = REQUIRED_CONFIG.concat(""" - payments: - disallowedPrefixes: - - +44 - """); - - final DynamicPaymentsConfiguration config = - DynamicConfigurationManager.parseConfiguration(paymentsConfigYaml, DynamicConfiguration.class).orElseThrow() - .getPaymentsConfiguration(); - - assertEquals(List.of("+44"), config.getDisallowedPrefixes()); - } - } - - @Test - void testParseCaptchaConfiguration() throws JsonProcessingException { - { - final String emptyConfigYaml = "test: true"; - - assertTrue(DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).isEmpty(), - "empty config should not validate"); - } - - { - final String captchaConfig = """ - captcha: - signupCountryCodes: - - 1 - scoreFloor: null - """; - - assertTrue(DynamicConfigurationManager.parseConfiguration(captchaConfig, DynamicConfiguration.class).isEmpty(), - "score floor must not be null"); - } - - { - final String captchaConfig = """ - captcha: - signupCountryCodes: - - 1 - scoreFloor: 0.9 - """; - - final DynamicCaptchaConfiguration config = - DynamicConfigurationManager.parseConfiguration(captchaConfig, DynamicConfiguration.class).orElseThrow() - .getCaptchaConfiguration(); - - assertEquals(Set.of("1"), config.getSignupCountryCodes()); - assertEquals(0.9f, config.getScoreFloor().floatValue()); - } - } - - @Test - void testParseLimits() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertThat(emptyConfig.getLimits().getRateLimitReset().getBucketSize()).isEqualTo(2); - assertThat(emptyConfig.getLimits().getRateLimitReset().getLeakRatePerMinute()).isEqualTo(2.0 / (60 * 24)); - } - - { - final String limitsConfig = REQUIRED_CONFIG.concat(""" - limits: - rateLimitReset: - bucketSize: 17 - leakRatePerMinute: 44 - """); - - final RateLimitConfiguration resetRateLimitConfiguration = - DynamicConfigurationManager.parseConfiguration(limitsConfig, DynamicConfiguration.class).orElseThrow() - .getLimits().getRateLimitReset(); - - assertThat(resetRateLimitConfiguration.getBucketSize()).isEqualTo(17); - assertThat(resetRateLimitConfiguration.getLeakRatePerMinute()).isEqualTo(44); - } - } - - @Test - void testParseRateLimitReset() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertThat(emptyConfig.getRateLimitChallengeConfiguration().getClientSupportedVersions()).isEmpty(); - } - - { - final String rateLimitChallengeConfig = REQUIRED_CONFIG.concat(""" - rateLimitChallenge: - clientSupportedVersions: - IOS: 5.1.0 - ANDROID: 5.2.0 - DESKTOP: 5.0.0 - """); - - DynamicRateLimitChallengeConfiguration rateLimitChallengeConfiguration = - DynamicConfigurationManager.parseConfiguration(rateLimitChallengeConfig, DynamicConfiguration.class).orElseThrow() - .getRateLimitChallengeConfiguration(); - - final Map clientSupportedVersions = rateLimitChallengeConfiguration.getClientSupportedVersions(); - - assertThat(clientSupportedVersions.get(ClientPlatform.IOS)).isEqualTo(new Semver("5.1.0")); - assertThat(clientSupportedVersions.get(ClientPlatform.ANDROID)).isEqualTo(new Semver("5.2.0")); - assertThat(clientSupportedVersions.get(ClientPlatform.DESKTOP)).isEqualTo(new Semver("5.0.0")); - } - } - - @Test - void testParseDirectoryReconciler() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertThat(emptyConfig.getDirectoryReconcilerConfiguration().isEnabled()).isTrue(); - } - - { - final String directoryReconcilerConfig = REQUIRED_CONFIG.concat(""" - directoryReconciler: - enabled: false - """); - - DynamicDirectoryReconcilerConfiguration directoryReconcilerConfiguration = - DynamicConfigurationManager.parseConfiguration(directoryReconcilerConfig, DynamicConfiguration.class).orElseThrow() - .getDirectoryReconcilerConfiguration(); - - assertThat(directoryReconcilerConfiguration.isEnabled()).isFalse(); - } - } - - @Test - void testParseTurnConfig() throws JsonProcessingException { - { - final String config = REQUIRED_CONFIG.concat(""" - turn: - secret: bloop - uriConfigs: - - uris: - - turn:test.org - weight: -1 - """); - assertThat(DynamicConfigurationManager.parseConfiguration(config, DynamicConfiguration.class)).isEmpty(); - } - { - final String config = REQUIRED_CONFIG.concat(""" - turn: - secret: bloop - uriConfigs: - - uris: - - turn:test0.org - - turn:test1.org - - uris: - - turn:test2.org - weight: 2 - enrolledNumbers: - - +15555555555 - """); - DynamicTurnConfiguration turnConfiguration = DynamicConfigurationManager - .parseConfiguration(config, DynamicConfiguration.class) - .orElseThrow() - .getTurnConfiguration(); - assertThat(turnConfiguration.getSecret()).isEqualTo("bloop"); - assertThat(turnConfiguration.getUriConfigs().get(0).getUris()).hasSize(2); - assertThat(turnConfiguration.getUriConfigs().get(1).getUris()).hasSize(1); - assertThat(turnConfiguration.getUriConfigs().get(0).getWeight()).isEqualTo(1); - assertThat(turnConfiguration.getUriConfigs().get(1).getWeight()).isEqualTo(2); - assertThat(turnConfiguration.getUriConfigs().get(1).getEnrolledNumbers()).containsExactly("+15555555555"); - - } - } - - @Test - void testMessagePersister() throws JsonProcessingException { - { - final String emptyConfigYaml = REQUIRED_CONFIG.concat("test: true"); - final DynamicConfiguration emptyConfig = - DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow(); - - assertTrue(emptyConfig.getMessagePersisterConfiguration().isPersistenceEnabled()); - } - - { - final String messagePersisterEnabledYaml = REQUIRED_CONFIG.concat(""" - messagePersister: - persistenceEnabled: true - """); - - final DynamicConfiguration config = - DynamicConfigurationManager.parseConfiguration(messagePersisterEnabledYaml, DynamicConfiguration.class) - .orElseThrow(); - - assertTrue(config.getMessagePersisterConfiguration().isPersistenceEnabled()); - } - - { - final String messagePersisterDisabledYaml = REQUIRED_CONFIG.concat(""" - messagePersister: - persistenceEnabled: false - """); - - final DynamicConfiguration config = - DynamicConfigurationManager.parseConfiguration(messagePersisterDisabledYaml, DynamicConfiguration.class) - .orElseThrow(); - - assertFalse(config.getMessagePersisterConfiguration().isPersistenceEnabled()); - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java deleted file mode 100644 index 30d162014..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java +++ /dev/null @@ -1,2238 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.anyLong; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import com.google.common.net.HttpHeaders; -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.time.Duration; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collections; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.apache.commons.lang3.RandomUtils; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.signal.libsignal.usernames.BaseUsernameException; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.auth.StoredRegistrationLock; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; -import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; -import org.whispersystems.textsecuregcm.captcha.AssessmentResult; -import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; -import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCaptchaConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse; -import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; -import org.whispersystems.textsecuregcm.entities.ApnRegistrationId; -import org.whispersystems.textsecuregcm.entities.ChangePhoneNumberRequest; -import org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest; -import org.whispersystems.textsecuregcm.entities.GcmRegistrationId; -import org.whispersystems.textsecuregcm.entities.IncomingMessage; -import org.whispersystems.textsecuregcm.entities.RegistrationLock; -import org.whispersystems.textsecuregcm.entities.RegistrationLockFailure; -import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest; -import org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.entities.UsernameHashResponse; -import org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberResponse; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.push.PushNotification; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.registration.ClientType; -import org.whispersystems.textsecuregcm.registration.MessageTransport; -import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException; -import org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.util.TestClock; -import org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier; - -@ExtendWith(DropwizardExtensionsSupport.class) -class AccountControllerTest { - private static final String SENDER = "+14152222222"; - private static final String SENDER_OLD = "+14151111111"; - private static final String SENDER_PIN = "+14153333333"; - private static final String SENDER_OVER_PIN = "+14154444444"; - private static final String SENDER_OVER_PREFIX = "+14156666666"; - private static final String SENDER_PREAUTH = "+14157777777"; - private static final String SENDER_REG_LOCK = "+14158888888"; - private static final String SENDER_HAS_STORAGE = "+14159999999"; - private static final String SENDER_TRANSFER = "+14151111112"; - private static final String RESTRICTED_COUNTRY = "800"; - private static final String RESTRICTED_NUMBER = "+" + RESTRICTED_COUNTRY + "11111111"; - private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE"; - private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc"; - - private static final String INVALID_BASE_64_URL_USERNAME_HASH = "fA+VkNbvB6dVfx/6NpaRSK6mvhhAUBgDNWFaD7+7gvs="; - private static final String TOO_SHORT_BASE_64_URL_USERNAME_HASH = "P2oMuxx0xgGxSpTO0ACq3IztEOBDaV9t9YFu4bAGpQ"; - private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1); - private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2); - private static final byte[] INVALID_USERNAME_HASH = Base64.getDecoder().decode(INVALID_BASE_64_URL_USERNAME_HASH); - private static final byte[] TOO_SHORT_USERNAME_HASH = Base64.getUrlDecoder().decode(TOO_SHORT_BASE_64_URL_USERNAME_HASH); - private static final String BASE_64_URL_ZK_PROOF = "2kambOgmdeeIO0faCMgR6HR4G2BQ5bnhXdIe9ZuZY0NmQXSra5BzDBQ7jzy1cvoEqUHYLpBYMrXudkYPJaWoQg"; - private static final byte[] ZK_PROOF = Base64.getUrlDecoder().decode(BASE_64_URL_ZK_PROOF); - private static final UUID SENDER_REG_LOCK_UUID = UUID.randomUUID(); - private static final UUID SENDER_TRANSFER_UUID = UUID.randomUUID(); - - private static final String NICE_HOST = "127.0.0.1"; - private static final String RATE_LIMITED_IP_HOST = "10.0.0.1"; - private static final String RATE_LIMITED_PREFIX_HOST = "10.0.0.2"; - private static final String RATE_LIMITED_HOST2 = "10.0.0.3"; - - private static final String VALID_CAPTCHA_TOKEN = "valid_token"; - private static final String INVALID_CAPTCHA_TOKEN = "invalid_token"; - - private static final String TEST_NUMBER = "+14151111113"; - - private static StoredVerificationCodeManager pendingAccountsManager = mock(StoredVerificationCodeManager.class); - private static AccountsManager accountsManager = mock(AccountsManager.class); - private static RateLimiters rateLimiters = mock(RateLimiters.class); - private static RateLimiter rateLimiter = mock(RateLimiter.class); - private static RateLimiter pinLimiter = mock(RateLimiter.class); - private static RateLimiter smsVoiceIpLimiter = mock(RateLimiter.class); - private static RateLimiter smsVoicePrefixLimiter = mock(RateLimiter.class); - private static RateLimiter autoBlockLimiter = mock(RateLimiter.class); - private static RateLimiter usernameSetLimiter = mock(RateLimiter.class); - private static RateLimiter usernameReserveLimiter = mock(RateLimiter.class); - private static RateLimiter usernameLookupLimiter = mock(RateLimiter.class); - private static RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class); - private static TurnTokenGenerator turnTokenGenerator = mock(TurnTokenGenerator.class); - private static Account senderPinAccount = mock(Account.class); - private static Account senderRegLockAccount = mock(Account.class); - private static Account senderHasStorage = mock(Account.class); - private static Account senderTransfer = mock(Account.class); - private static CaptchaChecker captchaChecker = mock(CaptchaChecker.class); - private static PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class); - private static ChangeNumberManager changeNumberManager = mock(ChangeNumberManager.class); - private static RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock( - RegistrationRecoveryPasswordsManager.class); - private static ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class); - private static final UsernameHashZkProofVerifier usernameZkProofVerifier = mock(UsernameHashZkProofVerifier.class); - private static TestClock testClock = TestClock.now(); - - private static DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); - private byte[] registration_lock_key = new byte[32]; - - private static final SecureBackupServiceConfiguration BACKUP_CFG = MockUtils.buildMock( - SecureBackupServiceConfiguration.class, - cfg -> when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32])); - - private static final ExternalServiceCredentialsGenerator backupCredentialsGenerator = SecureBackupController.credentialsGenerator( - BACKUP_CFG); - - private static final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager( - accountsManager, clientPresenceManager, backupCredentialsGenerator, rateLimiters); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider( - new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, - DisabledPermittedAuthenticatedAccount.class))) - .addProvider(new JsonMappingExceptionMapper()) - .addProvider(new RateLimitExceededExceptionMapper()) - .addProvider(new ImpossiblePhoneNumberExceptionMapper()) - .addProvider(new NonNormalizedPhoneNumberExceptionMapper()) - .addProvider(new RateLimitByIpFilter(rateLimiters)) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new AccountController(pendingAccountsManager, - accountsManager, - rateLimiters, - registrationServiceClient, - dynamicConfigurationManager, - turnTokenGenerator, - Map.of(TEST_NUMBER, 123456), - captchaChecker, - pushNotificationManager, - changeNumberManager, - registrationLockVerificationManager, - registrationRecoveryPasswordsManager, - usernameZkProofVerifier, - testClock)) - .build(); - - - @BeforeEach - void setup() throws Exception { - clearInvocations(AuthHelper.VALID_ACCOUNT, AuthHelper.UNDISCOVERABLE_ACCOUNT); - - new SecureRandom().nextBytes(registration_lock_key); - SaltedTokenHash registrationLockCredentials = SaltedTokenHash.generateFor( - HexFormat.of().formatHex(registration_lock_key)); - - AccountsHelper.setupMockUpdate(accountsManager); - - when(rateLimiters.getSmsDestinationLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getVoiceDestinationLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getVoiceDestinationDailyLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getVerifyLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getPinLimiter()).thenReturn(pinLimiter); - when(rateLimiters.getSmsVoiceIpLimiter()).thenReturn(smsVoiceIpLimiter); - when(rateLimiters.getSmsVoicePrefixLimiter()).thenReturn(smsVoicePrefixLimiter); - when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter); - when(rateLimiters.getUsernameReserveLimiter()).thenReturn(usernameReserveLimiter); - when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter); - - when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis()); - when(senderPinAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis())); - - when(senderHasStorage.getUuid()).thenReturn(UUID.randomUUID()); - when(senderHasStorage.isStorageSupported()).thenReturn(true); - when(senderHasStorage.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis())); - - when(senderRegLockAccount.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.of(registrationLockCredentials.hash()), Optional.of(registrationLockCredentials.salt()), System.currentTimeMillis())); - when(senderRegLockAccount.getLastSeen()).thenReturn(System.currentTimeMillis()); - when(senderRegLockAccount.getUuid()).thenReturn(SENDER_REG_LOCK_UUID); - when(senderRegLockAccount.getNumber()).thenReturn(SENDER_REG_LOCK); - - when(senderTransfer.getRegistrationLock()).thenReturn(new StoredRegistrationLock(Optional.empty(), Optional.empty(), System.currentTimeMillis())); - when(senderTransfer.getUuid()).thenReturn(SENDER_TRANSFER_UUID); - when(senderTransfer.getNumber()).thenReturn(SENDER_TRANSFER); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_OLD)).thenReturn(Optional.empty()); - when(pendingAccountsManager.getCodeForNumber(SENDER_PIN)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PIN)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_OVER_PREFIX)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_PREAUTH)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "validchallenge", null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_HAS_STORAGE)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null))); - when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, null))); - - when(accountsManager.getByE164(eq(SENDER_PIN))).thenReturn(Optional.of(senderPinAccount)); - when(accountsManager.getByE164(eq(SENDER_REG_LOCK))).thenReturn(Optional.of(senderRegLockAccount)); - when(accountsManager.getByE164(eq(SENDER_OVER_PIN))).thenReturn(Optional.of(senderPinAccount)); - when(accountsManager.getByE164(eq(SENDER))).thenReturn(Optional.empty()); - when(accountsManager.getByE164(eq(SENDER_OLD))).thenReturn(Optional.empty()); - when(accountsManager.getByE164(eq(SENDER_PREAUTH))).thenReturn(Optional.empty()); - when(accountsManager.getByE164(eq(SENDER_HAS_STORAGE))).thenReturn(Optional.of(senderHasStorage)); - when(accountsManager.getByE164(eq(SENDER_TRANSFER))).thenReturn(Optional.of(senderTransfer)); - - when(accountsManager.create(any(), any(), any(), any(), any())).thenAnswer((Answer) invocation -> { - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(account.getNumber()).thenReturn(invocation.getArgument(0, String.class)); - when(account.getBadges()).thenReturn(invocation.getArgument(4, List.class)); - - return account; - }); - - when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer((Answer) invocation -> { - final Account account = invocation.getArgument(0, Account.class); - final String number = invocation.getArgument(1, String.class); - final String pniIdentityKey = invocation.getArgument(2, String.class); - - final UUID uuid = account.getUuid(); - final List devices = account.getDevices(); - - final Account updatedAccount = mock(Account.class); - when(updatedAccount.getUuid()).thenReturn(uuid); - when(updatedAccount.getNumber()).thenReturn(number); - when(updatedAccount.getPhoneNumberIdentityKey()).thenReturn(pniIdentityKey); - when(updatedAccount.getPhoneNumberIdentifier()).thenReturn(UUID.randomUUID()); - when(updatedAccount.getDevices()).thenReturn(devices); - - for (long i = 1; i <= 3; i++) { - final Optional d = account.getDevice(i); - when(updatedAccount.getDevice(i)).thenReturn(d); - } - - return updatedAccount; - }); - - { - DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - when(dynamicConfigurationManager.getConfiguration()) - .thenReturn(dynamicConfiguration); - - DynamicCaptchaConfiguration signupCaptchaConfig = new DynamicCaptchaConfiguration(); - signupCaptchaConfig.setSignupCountryCodes(Set.of(RESTRICTED_COUNTRY)); - - when(dynamicConfiguration.getCaptchaConfiguration()).thenReturn(signupCaptchaConfig); - } - when(captchaChecker.verify(eq(INVALID_CAPTCHA_TOKEN), anyString())) - .thenReturn(AssessmentResult.invalid()); - when(captchaChecker.verify(eq(VALID_CAPTCHA_TOKEN), anyString())) - .thenReturn(new AssessmentResult(true, "")); - - doThrow(new RateLimitExceededException(Duration.ZERO, true)).when(pinLimiter).validate(eq(SENDER_OVER_PIN)); - - doThrow(new RateLimitExceededException(Duration.ZERO, true)).when(smsVoicePrefixLimiter) - .validate(SENDER_OVER_PREFIX.substring(0, 4 + 2)); - doThrow(new RateLimitExceededException(Duration.ZERO, true)).when(smsVoiceIpLimiter).validate(RATE_LIMITED_IP_HOST); - doThrow(new RateLimitExceededException(Duration.ZERO, true)).when(smsVoiceIpLimiter).validate(RATE_LIMITED_HOST2); - } - - @AfterEach - void teardown() { - reset( - pendingAccountsManager, - accountsManager, - rateLimiters, - rateLimiter, - pinLimiter, - smsVoiceIpLimiter, - smsVoicePrefixLimiter, - usernameSetLimiter, - usernameReserveLimiter, - usernameLookupLimiter, - registrationServiceClient, - turnTokenGenerator, - senderPinAccount, - senderRegLockAccount, - senderHasStorage, - senderTransfer, - captchaChecker, - pushNotificationManager, - changeNumberManager, - clientPresenceManager, - usernameZkProofVerifier); - - clearInvocations(AuthHelper.DISABLED_DEVICE); - } - - @Test - void testGetFcmPreauth() throws NumberParseException { - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(new byte[16])); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.empty()); - - Response response = resources.getJerseyTest() - .target("/v1/accounts/fcm/preauth/mytoken/" + SENDER) - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - - verify(registrationServiceClient).createRegistrationSession( - eq(PhoneNumberUtil.getInstance().parse(SENDER, null)), any()); - - verify(pushNotificationManager).sendRegistrationChallengeNotification( - eq("mytoken"), eq(PushNotification.TokenType.FCM), challengeTokenCaptor.capture()); - - assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); - } - - @Test - void testGetFcmPreauthIvoryCoast() throws NumberParseException { - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(new byte[16])); - - Response response = resources.getJerseyTest() - .target("/v1/accounts/fcm/preauth/mytoken/+2250707312345") - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - - verify(registrationServiceClient).createRegistrationSession( - eq(PhoneNumberUtil.getInstance().parse("+2250707312345", null)), any()); - - verify(pushNotificationManager).sendRegistrationChallengeNotification( - eq("mytoken"), eq(PushNotification.TokenType.FCM), challengeTokenCaptor.capture()); - - assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); - } - - @Test - void testGetApnPreauth() throws NumberParseException { - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(new byte[16])); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.empty()); - - Response response = resources.getJerseyTest() - .target("/v1/accounts/apn/preauth/mytoken/" + SENDER) - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - - verify(registrationServiceClient).createRegistrationSession( - eq(PhoneNumberUtil.getInstance().parse(SENDER, null)), any()); - - verify(pushNotificationManager).sendRegistrationChallengeNotification( - eq("mytoken"), eq(PushNotification.TokenType.APN_VOIP), challengeTokenCaptor.capture()); - - assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); - } - - @Test - void testGetApnPreauthExplicitVoip() throws NumberParseException { - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(new byte[16])); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.empty()); - - Response response = resources.getJerseyTest() - .target("/v1/accounts/apn/preauth/mytoken/" + SENDER) - .queryParam("voip", "true") - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - - verify(registrationServiceClient).createRegistrationSession( - eq(PhoneNumberUtil.getInstance().parse(SENDER, null)), any()); - - verify(pushNotificationManager).sendRegistrationChallengeNotification( - eq("mytoken"), eq(PushNotification.TokenType.APN_VOIP), challengeTokenCaptor.capture()); - - assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); - } - - @Test - void testGetApnPreauthExplicitNoVoip() throws NumberParseException { - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(new byte[16])); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.empty()); - - Response response = resources.getJerseyTest() - .target("/v1/accounts/apn/preauth/mytoken/" + SENDER) - .queryParam("voip", "false") - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - - verify(registrationServiceClient).createRegistrationSession( - eq(PhoneNumberUtil.getInstance().parse(SENDER, null)), any()); - - verify(pushNotificationManager).sendRegistrationChallengeNotification( - eq("mytoken"), eq(PushNotification.TokenType.APN), challengeTokenCaptor.capture()); - - assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); - } - - @Test - void testGetPreauthImpossibleNumber() { - final Response response = resources.getJerseyTest() - .target("/v1/accounts/fcm/preauth/mytoken/BogusNumber") - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(400); - assertThat(response.readEntity(String.class)).isBlank(); - - verifyNoInteractions(registrationServiceClient); - verifyNoInteractions(pushNotificationManager); - } - - @Test - void testGetPreauthNonNormalized() { - final String number = "+4407700900111"; - - final Response response = resources.getJerseyTest() - .target("/v1/accounts/fcm/preauth/mytoken/" + number) - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(400); - - final NonNormalizedPhoneNumberResponse responseEntity = response.readEntity(NonNormalizedPhoneNumberResponse.class); - assertThat(responseEntity.getOriginalNumber()).isEqualTo(number); - assertThat(responseEntity.getNormalizedNumber()).isEqualTo("+447700900111"); - - verifyNoInteractions(registrationServiceClient); - verifyNoInteractions(pushNotificationManager); - } - - @Test - void testGetPreauthExistingSession() throws NumberParseException { - final String existingPushCode = "existing-push-code"; - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(new byte[16])); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn( - Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), existingPushCode, new byte[16]))); - - Response response = resources.getJerseyTest() - .target("/v1/accounts/apn/preauth/mytoken/" + SENDER) - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - - verify(registrationServiceClient, never()).createRegistrationSession(any(), any()); - - verify(pushNotificationManager).sendRegistrationChallengeNotification( - eq("mytoken"), eq(PushNotification.TokenType.APN_VOIP), challengeTokenCaptor.capture()); - - assertThat(challengeTokenCaptor.getValue()).isEqualTo(existingPushCode); - } - - @Test - void testGetPreauthExistingSessionWithoutPushCode() throws NumberParseException { - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(new byte[16])); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn( - Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), null, new byte[16]))); - - Response response = resources.getJerseyTest() - .target("/v1/accounts/apn/preauth/mytoken/" + SENDER) - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - final ArgumentCaptor challengeTokenCaptor = ArgumentCaptor.forClass(String.class); - - verify(registrationServiceClient, never()).createRegistrationSession(any(), any()); - - verify(pushNotificationManager).sendRegistrationChallengeNotification( - eq("mytoken"), eq(PushNotification.TokenType.APN_VOIP), challengeTokenCaptor.capture()); - - assertThat(challengeTokenCaptor.getValue().length()).isEqualTo(32); - } - - @Test - void testSendCodeWithExistingSessionFromPreauth() { - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER)) - .thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId))); - - when(registrationServiceClient.sendRegistrationCode(eq(sessionId), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - verify(pendingAccountsManager).store(eq(SENDER), argThat( - storedVerificationCode -> Arrays.equals(storedVerificationCode.sessionId(), sessionId) && - "1234-push".equals(storedVerificationCode.pushCode()))); - } - - @Test - void testSendCode() { - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - verify(pendingAccountsManager).store(eq(SENDER), argThat( - storedVerificationCode -> Arrays.equals(storedVerificationCode.sessionId(), sessionId) && - "1234-push".equals(storedVerificationCode.pushCode()))); - } - - @Test - void testSendCodeRateLimited() { - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(Duration.ofMinutes(10), true))); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(413); - - verify(registrationServiceClient, never()).sendRegistrationCode(any(), any(), any(), any(), any()); - } - - @Test - void testSendCodeImpossibleNumber() { - final Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", "Definitely not a real number")) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(400); - assertThat(response.readEntity(String.class)).isBlank(); - - verify(registrationServiceClient, never()).sendRegistrationCode(any(), any(), any(), any(), any()); - } - - @Test - void testSendCodeNonNormalized() { - final String number = "+4407700900111"; - - final Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", number)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(400); - - final NonNormalizedPhoneNumberResponse responseEntity = response.readEntity(NonNormalizedPhoneNumberResponse.class); - assertThat(responseEntity.getOriginalNumber()).isEqualTo(number); - assertThat(responseEntity.getNormalizedNumber()).isEqualTo("+447700900111"); - - verify(registrationServiceClient, never()).sendRegistrationCode(any(), any(), any(), any(), any()); - } - - @Test - public void testSendCodeVoiceNoLocale() { - - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/voice/code/%s", SENDER)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.VOICE, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testSendCodeWithValidPreauth() { - - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER_PREAUTH)) - .queryParam("challenge", "validchallenge") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testSendCodeWithInvalidPreauth() { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER_PREAUTH)) - .queryParam("challenge", "invalidchallenge") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(403); - - verifyNoInteractions(registrationServiceClient); - } - - @Test - void testSendCodeWithNoPreauth() { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER_PREAUTH)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(402); - - verifyNoInteractions(registrationServiceClient); - } - - @Test - void testSendiOSCode() { - - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("client", "ios") - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.IOS, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testSendAndroidNgCode() { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("client", "android-ng") - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.ANDROID_WITHOUT_FCM, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testSendWithValidCaptcha() throws NumberParseException, IOException { - - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("captcha", VALID_CAPTCHA_TOKEN) - .request() - .header("X-Forwarded-For", NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(captchaChecker).verify(eq(VALID_CAPTCHA_TOKEN), eq(NICE_HOST)); - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testSendWithInvalidCaptcha() throws IOException { - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("captcha", INVALID_CAPTCHA_TOKEN) - .request() - .header("X-Forwarded-For", NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(402); - - verify(captchaChecker).verify(eq(INVALID_CAPTCHA_TOKEN), eq(NICE_HOST)); - verifyNoInteractions(registrationServiceClient); - } - - @Test - void testSendRateLimitedHost() { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, RATE_LIMITED_IP_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(402); - - verifyNoInteractions(captchaChecker); - verifyNoInteractions(registrationServiceClient); - } - - @Test - void testSendRateLimitedPrefixAutoBlock() { - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER_OVER_PREFIX)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, RATE_LIMITED_PREFIX_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(402); - - verifyNoInteractions(captchaChecker); - verifyNoInteractions(registrationServiceClient); - } - - @Test - void testSendRestrictedHostOut() { - - final String challenge = "challenge"; - when(pendingAccountsManager.getCodeForNumber(RESTRICTED_NUMBER)) - .thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), challenge, null))); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", RESTRICTED_NUMBER)) - .queryParam("challenge", challenge) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(402); - - verifyNoInteractions(registrationServiceClient); - } - - @ParameterizedTest - @CsvSource({ - "+12025550123, true", - "+12505550199, false", - }) - void testRestrictedRegion(final String number, final boolean expectSendCode) throws NumberParseException { - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - - final DynamicCaptchaConfiguration signupCaptchaConfig = new DynamicCaptchaConfiguration(); - signupCaptchaConfig.setSignupRegions(Set.of("CA")); - - when(dynamicConfiguration.getCaptchaConfiguration()).thenReturn(signupCaptchaConfig); - - final String challenge = "challenge"; - when(pendingAccountsManager.getCodeForNumber(number)) - .thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), challenge, null))); - - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", number)) - .queryParam("challenge", challenge) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - if (expectSendCode) { - assertThat(response.getStatus()).isEqualTo(200); - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } else { - assertThat(response.getStatus()).isEqualTo(402); - verifyNoInteractions(registrationServiceClient); - } - } - - @Test - void testSendRestrictedIn() { - - final String challenge = "challenge"; - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER)).thenReturn(Optional.of(new StoredVerificationCode(null, System.currentTimeMillis(), challenge, null))); - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("challenge", challenge) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testSendCodeTestDeviceNumber() { - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", TEST_NUMBER)) - .request() - .header("X-Forwarded-For", RATE_LIMITED_IP_HOST) - .get(); - - final ArgumentCaptor captor = ArgumentCaptor.forClass(StoredVerificationCode.class); - verify(pendingAccountsManager).store(eq(TEST_NUMBER), captor.capture()); - assertThat(captor.getValue().code()).isNull(); - assertThat(captor.getValue().sessionId()).isEqualTo(sessionId); - assertThat(response.getStatus()).isEqualTo(200); - - // Even though no actual SMS will be sent, we leave that decision to the registration service - verify(registrationServiceClient).sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testVerifyCode() throws Exception { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - resources.getJerseyTest() - .target("/v1/accounts/code/1234") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER, "bar")) - .put(Entity.entity(new AccountAttributes(), MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - verify(accountsManager).create(eq(SENDER), eq("bar"), any(), any(), anyList()); - - verify(registrationServiceClient).checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT); - } - - @Test - void testVerifyCodeBadCredentials() { - final Response response = resources.getJerseyTest() - .target("/v1/accounts/code/1234") - .request() - .header("Authorization", "This is not a valid authorization header") - .put(Entity.entity(new AccountAttributes(), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testVerifyCodeOld() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/code/1234") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_OLD, "bar")) - .put(Entity.entity(new AccountAttributes(false, 2222, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(403); - - verifyNoInteractions(accountsManager); - } - - @Test - void testVerifyBadCode() { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(false)); - - Response response = - resources.getJerseyTest() - .target("/v1/accounts/code/1111") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER, "bar")) - .put(Entity.entity(new AccountAttributes(false, 3333, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(403); - - verify(registrationServiceClient).checkVerificationCode(sessionId, "1111", AccountController.REGISTRATION_RPC_TIMEOUT); - verifyNoInteractions(accountsManager); - } - - @Test - void testVerifyRegistrationLock() throws Exception { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - AccountIdentityResponse result = - resources.getJerseyTest() - .target("/v1/accounts/code/666666") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_REG_LOCK, "bar")) - .put(Entity.entity( - new AccountAttributes(false, 3333, null, HexFormat.of().formatHex(registration_lock_key), true, null), - MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - assertThat(result.uuid()).isNotNull(); - - verify(pinLimiter).validate(eq(SENDER_REG_LOCK)); - } - - @Test - void testVerifyRegistrationLockSetsRegistrationLockOnNewAccount() throws Exception { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - AccountIdentityResponse result = - resources.getJerseyTest() - .target("/v1/accounts/code/666666") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_REG_LOCK, "bar")) - .put(Entity.entity( - new AccountAttributes(false, 3333, null, HexFormat.of().formatHex(registration_lock_key), true, null), - MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - assertThat(result.uuid()).isNotNull(); - - verify(pinLimiter).validate(eq(SENDER_REG_LOCK)); - - verify(accountsManager).create(eq(SENDER_REG_LOCK), eq("bar"), any(), argThat( - attributes -> HexFormat.of().formatHex(registration_lock_key).equals(attributes.getRegistrationLock())), - argThat(List::isEmpty)); - } - - @Test - void testVerifyRegistrationLockOld() { - StoredRegistrationLock lock = senderRegLockAccount.getRegistrationLock(); - - try { - when(senderRegLockAccount.getRegistrationLock()).thenReturn(lock.forTime(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))); - - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - AccountIdentityResponse result = - resources.getJerseyTest() - .target("/v1/accounts/code/666666") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_REG_LOCK, "bar")) - .put(Entity.entity(new AccountAttributes(false, 3333, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - assertThat(result.uuid()).isNotNull(); - - verifyNoInteractions(pinLimiter); - } finally { - when(senderRegLockAccount.getRegistrationLock()).thenReturn(lock); - } - } - - @Test - void testVerifyWrongRegistrationLock() throws Exception { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - Response response = - resources.getJerseyTest() - .target("/v1/accounts/code/666666") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_REG_LOCK, "bar")) - .put(Entity.entity(new AccountAttributes(false, 3333, null, - HexFormat.of().formatHex(new byte[32]), true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(423); - - // verify(senderRegLockAccount).lockAuthenticationCredentials(); - // verify(clientPresenceManager, times(1)).disconnectAllPresences(eq(SENDER_REG_LOCK_UUID), any()); - verify(pinLimiter).validate(eq(SENDER_REG_LOCK)); - } - - @Test - void testVerifyNoRegistrationLock() { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_REG_LOCK)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "666666-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "666666", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - Response response = - resources.getJerseyTest() - .target("/v1/accounts/code/666666") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_REG_LOCK, "bar")) - .put(Entity.entity(new AccountAttributes(false, 3333, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(423); - - RegistrationLockFailure failure = response.readEntity(RegistrationLockFailure.class); - assertThat(failure.backupCredentials()).isNotNull(); - assertThat(failure.backupCredentials().username()).isEqualTo(SENDER_REG_LOCK_UUID.toString()); - assertThat(failure.backupCredentials().password()).isNotEmpty(); - assertThat(failure.backupCredentials().password().startsWith(SENDER_REG_LOCK_UUID.toString())).isTrue(); - assertThat(failure.timeRemaining()).isGreaterThan(0); - - // verify(senderRegLockAccount).lockAuthenticationCredentials(); - // verify(clientPresenceManager, atLeastOnce()).disconnectAllPresences(eq(SENDER_REG_LOCK_UUID), any()); - verifyNoInteractions(pinLimiter); - } - - @Test - void testVerifyTransferSupported() { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - when(senderTransfer.isTransferSupported()).thenReturn(true); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/code/1234") - .queryParam("transfer", true) - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_TRANSFER, "bar")) - .put(Entity.entity(new AccountAttributes(false, 2222, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(409); - } - - @Test - void testVerifyTransferNotSupported() { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - when(senderTransfer.isTransferSupported()).thenReturn(false); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/code/1234") - .queryParam("transfer", true) - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_TRANSFER, "bar")) - .put(Entity.entity(new AccountAttributes(false, 2222, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void testVerifyTransferSupportedNotRequested() { - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(SENDER_TRANSFER)) - .thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "1234-push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(sessionId, "1234", AccountController.REGISTRATION_RPC_TIMEOUT)) - .thenReturn(CompletableFuture.completedFuture(true)); - - when(senderTransfer.isTransferSupported()).thenReturn(true); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/code/1234") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(SENDER_TRANSFER, "bar")) - .put(Entity.entity(new AccountAttributes(false, 2222, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void testChangePhoneNumber() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - - final AccountIdentityResponse accountIdentityResponse = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - verify(registrationServiceClient).checkVerificationCode(sessionId, code, AccountController.REGISTRATION_RPC_TIMEOUT); - - verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), eq(number), any(), any(), any(), any()); - - assertThat(accountIdentityResponse.uuid()).isEqualTo(AuthHelper.VALID_UUID); - assertThat(accountIdentityResponse.number()).isEqualTo(number); - assertThat(accountIdentityResponse.pni()).isNotEqualTo(AuthHelper.VALID_PNI); - } - - @Test - void testChangePhoneNumberImpossibleNumber() throws Exception { - final String number = "This is not a real phone number"; - final String code = "987654"; - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(400); - assertThat(response.readEntity(String.class)).isBlank(); - verify(changeNumberManager, never()).changeNumber(any(), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberNonNormalized() throws Exception { - final String number = "+4407700900111"; - final String code = "987654"; - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(400); - - final NonNormalizedPhoneNumberResponse responseEntity = response.readEntity(NonNormalizedPhoneNumberResponse.class); - assertThat(responseEntity.getOriginalNumber()).isEqualTo(number); - assertThat(responseEntity.getNormalizedNumber()).isEqualTo("+447700900111"); - - verify(changeNumberManager, never()).changeNumber(any(), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberSameNumber() throws Exception { - final AccountIdentityResponse accountIdentityResponse = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(AuthHelper.VALID_NUMBER, "567890", null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberNoPendingCode() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.empty()); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(403); - verify(changeNumberManager, never()).changeNumber(any(), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberIncorrectCode() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(false)); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - verify(registrationServiceClient).checkVerificationCode(sessionId, code, AccountController.REGISTRATION_RPC_TIMEOUT); - - assertThat(response.getStatus()).isEqualTo(403); - verify(changeNumberManager, never()).changeNumber(any(), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberExistingAccountReglockNotRequired() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - - final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); - when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(false); - - final Account existingAccount = mock(Account.class); - when(existingAccount.getNumber()).thenReturn(number); - when(existingAccount.getUuid()).thenReturn(UUID.randomUUID()); - when(existingAccount.getRegistrationLock()).thenReturn(existingRegistrationLock); - - when(accountsManager.getByE164(number)).thenReturn(Optional.of(existingAccount)); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberExistingAccountReglockRequiredNotProvided() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(code, System.currentTimeMillis(), "push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - - final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); - when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true); - - final UUID existingUuid = UUID.randomUUID(); - final Account existingAccount = mock(Account.class); - when(existingAccount.getNumber()).thenReturn(number); - when(existingAccount.getUuid()).thenReturn(existingUuid); - when(existingAccount.getRegistrationLock()).thenReturn(existingRegistrationLock); - - when(accountsManager.getByE164(number)).thenReturn(Optional.of(existingAccount)); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, null, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(423); - - // verify(existingAccount).lockAuthenticationCredentials(); - // verify(clientPresenceManager, atLeastOnce()).disconnectAllPresences(eq(existingUuid), any()); - verify(changeNumberManager, never()).changeNumber(any(), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberExistingAccountReglockRequiredIncorrect() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - final String reglock = "setec-astronomy"; - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - - final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); - when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true); - when(existingRegistrationLock.verify(anyString())).thenReturn(false); - - UUID existingUuid = UUID.randomUUID(); - final Account existingAccount = mock(Account.class); - when(existingAccount.getNumber()).thenReturn(number); - when(existingAccount.getUuid()).thenReturn(existingUuid); - when(existingAccount.getRegistrationLock()).thenReturn(existingRegistrationLock); - - when(accountsManager.getByE164(number)).thenReturn(Optional.of(existingAccount)); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, reglock, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(423); - - // verify(existingAccount).lockAuthenticationCredentials(); - // verify(clientPresenceManager, atLeastOnce()).disconnectAllPresences(eq(existingUuid), any()); - verify(changeNumberManager, never()).changeNumber(any(), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberExistingAccountReglockRequiredCorrect() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - final String reglock = "setec-astronomy"; - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - - final StoredRegistrationLock existingRegistrationLock = mock(StoredRegistrationLock.class); - when(existingRegistrationLock.requiresClientRegistrationLock()).thenReturn(true); - when(existingRegistrationLock.verify(reglock)).thenReturn(true); - - final Account existingAccount = mock(Account.class); - when(existingAccount.getNumber()).thenReturn(number); - when(existingAccount.getUuid()).thenReturn(UUID.randomUUID()); - when(existingAccount.getRegistrationLock()).thenReturn(existingRegistrationLock); - - when(accountsManager.getByE164(number)).thenReturn(Optional.of(existingAccount)); - - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest(number, code, reglock, null, null, null, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - verify(senderRegLockAccount, never()).lockAuthTokenHash(); - verify(clientPresenceManager, never()).disconnectAllPresences(eq(SENDER_REG_LOCK_UUID), any()); - verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), any(), any(), any(), any(), any()); - } - - @Test - void testChangePhoneNumberChangePrekeys() throws Exception { - final String number = "+18005559876"; - final String code = "987654"; - final String pniIdentityKey = "changed-pni-identity-key"; - final byte[] sessionId = "session-id".getBytes(StandardCharsets.UTF_8); - - Device device2 = mock(Device.class); - when(device2.getId()).thenReturn(2L); - when(device2.isEnabled()).thenReturn(true); - when(device2.getRegistrationId()).thenReturn(2); - - Device device3 = mock(Device.class); - when(device3.getId()).thenReturn(3L); - when(device3.isEnabled()).thenReturn(true); - when(device3.getRegistrationId()).thenReturn(3); - - when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(AuthHelper.VALID_DEVICE, device2, device3)); - when(AuthHelper.VALID_ACCOUNT.getDevice(2L)).thenReturn(Optional.of(device2)); - when(AuthHelper.VALID_ACCOUNT.getDevice(3L)).thenReturn(Optional.of(device3)); - - when(pendingAccountsManager.getCodeForNumber(number)).thenReturn(Optional.of( - new StoredVerificationCode(null, System.currentTimeMillis(), "push", sessionId))); - - when(registrationServiceClient.checkVerificationCode(any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - - var deviceMessages = List.of( - new IncomingMessage(1, 2, 2, "content2"), - new IncomingMessage(1, 3, 3, "content3")); - var deviceKeys = Map.of(1L, new SignedPreKey(), 2L, new SignedPreKey(), 3L, new SignedPreKey()); - - final Map registrationIds = Map.of(1L, 17, 2L, 47, 3L, 89); - - final AccountIdentityResponse accountIdentityResponse = - resources.getJerseyTest() - .target("/v1/accounts/number") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ChangePhoneNumberRequest( - number, code, null, - pniIdentityKey, deviceMessages, - deviceKeys, - registrationIds), - MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), eq(number), any(), any(), any(), any()); - - assertThat(accountIdentityResponse.uuid()).isEqualTo(AuthHelper.VALID_UUID); - assertThat(accountIdentityResponse.number()).isEqualTo(number); - assertThat(accountIdentityResponse.pni()).isNotEqualTo(AuthHelper.VALID_PNI); - } - - @Test - void testSetRegistrationLock() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/registration_lock/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new RegistrationLock("1234567890123456789012345678901234567890123456789012345678901234"))); - - assertThat(response.getStatus()).isEqualTo(204); - - ArgumentCaptor pinCapture = ArgumentCaptor.forClass(String.class); - ArgumentCaptor pinSaltCapture = ArgumentCaptor.forClass(String.class); - - verify(AuthHelper.VALID_ACCOUNT, times(1)).setRegistrationLock(pinCapture.capture(), pinSaltCapture.capture()); - - assertThat(pinCapture.getValue()).isNotEmpty(); - assertThat(pinSaltCapture.getValue()).isNotEmpty(); - - assertThat(pinCapture.getValue().length()).isEqualTo(66); - } - - @Test - void testSetShortRegistrationLock() throws Exception { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/registration_lock/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new RegistrationLock("313"))); - - assertThat(response.getStatus()).isEqualTo(422); - } - - @Test - void testSetRegistrationLockDisabled() throws Exception { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/registration_lock/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .put(Entity.json(new RegistrationLock("1234567890123456789012345678901234567890123456789012345678901234"))); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testSetGcmId() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/gcm/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .put(Entity.json(new GcmRegistrationId("z000"))); - - assertThat(response.getStatus()).isEqualTo(204); - - verify(AuthHelper.DISABLED_DEVICE, times(1)).setGcmId(eq("z000")); - verify(accountsManager, times(1)).updateDevice(eq(AuthHelper.DISABLED_ACCOUNT), anyLong(), any()); - } - - @Test - void testSetApnId() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/apn/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .put(Entity.json(new ApnRegistrationId("first", "second"))); - - assertThat(response.getStatus()).isEqualTo(204); - - verify(AuthHelper.DISABLED_DEVICE, times(1)).setApnId(eq("first")); - verify(AuthHelper.DISABLED_DEVICE, times(1)).setVoipApnId(eq("second")); - verify(accountsManager, times(1)).updateDevice(eq(AuthHelper.DISABLED_ACCOUNT), anyLong(), any()); - } - - @Test - void testSetApnIdNoVoip() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/apn/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .put(Entity.json(new ApnRegistrationId("first", null))); - - assertThat(response.getStatus()).isEqualTo(204); - - verify(AuthHelper.DISABLED_DEVICE, times(1)).setApnId(eq("first")); - verify(AuthHelper.DISABLED_DEVICE, times(1)).setVoipApnId(null); - verify(accountsManager, times(1)).updateDevice(eq(AuthHelper.DISABLED_ACCOUNT), anyLong(), any()); - } - - @ParameterizedTest - @ValueSource(strings = {"/v1/accounts/whoami/", "/v1/accounts/me/"}) - public void testWhoAmI(final String path) { - AccountIdentityResponse response = - resources.getJerseyTest() - .target(path) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(AccountIdentityResponse.class); - - assertThat(response.uuid()).isEqualTo(AuthHelper.VALID_UUID); - } - - @Test - void testReserveUsernameHash() throws UsernameHashNotAvailableException { - when(accountsManager.reserveUsernameHash(any(), any())) - .thenReturn(new AccountsManager.UsernameReservation(null, USERNAME_HASH_1)); - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/reserve") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ReserveUsernameHashRequest(List.of(USERNAME_HASH_1, USERNAME_HASH_2)))); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.readEntity(ReserveUsernameHashResponse.class)) - .satisfies(r -> assertThat(r.usernameHash()).hasSize(32)); - } - - @Test - void testReserveUsernameHashUnavailable() throws UsernameHashNotAvailableException { - when(accountsManager.reserveUsernameHash(any(), anyList())) - .thenThrow(new UsernameHashNotAvailableException()); - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/reserve") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ReserveUsernameHashRequest(List.of(USERNAME_HASH_1, USERNAME_HASH_2)))); - assertThat(response.getStatus()).isEqualTo(409); - } - - @ParameterizedTest - @MethodSource - void testReserveUsernameHashListSizeInvalid(List usernameHashes) { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/reserve") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ReserveUsernameHashRequest(usernameHashes))); - assertThat(response.getStatus()).isEqualTo(422); - } - - static Stream testReserveUsernameHashListSizeInvalid() { - return Stream.of( - Arguments.of(Collections.nCopies(21, USERNAME_HASH_1)), - Arguments.of(Collections.emptyList()) - ); - } - - @Test - void testReserveUsernameHashInvalidHashSize() { - List usernameHashes = List.of(new byte[31]); - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/reserve") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ReserveUsernameHashRequest(usernameHashes))); - assertThat(response.getStatus()).isEqualTo(422); - } - - @Test - void testReserveUsernameHashNullList() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/reserve") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ReserveUsernameHashRequest(null))); - assertThat(response.getStatus()).isEqualTo(422); - } - - @Test - void testReserveUsernameHashInvalidBase64UrlEncoding() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/reserve") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json( - // Has '+' and '='characters which are invalid in base64url - """ - { - "usernameHashes": ["jh1jJ50oGn9wUXAFNtDus6AJgWOQ6XbZzF+wCv7OOQs="] - } - """)); - assertThat(response.getStatus()).isEqualTo(422); - } - - @Test - void testConfirmUsernameHash() - throws UsernameHashNotAvailableException, UsernameReservationNotFoundException, BaseUsernameException { - Account account = mock(Account.class); - when(account.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH_1)); - when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1))).thenReturn(account); - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/confirm") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF))); - assertThat(response.getStatus()).isEqualTo(200); - assertArrayEquals(response.readEntity(UsernameHashResponse.class).usernameHash(), USERNAME_HASH_1); - verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1); - } - - @Test - void testConfirmUnreservedUsernameHash() - throws UsernameHashNotAvailableException, UsernameReservationNotFoundException, BaseUsernameException { - when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1))) - .thenThrow(new UsernameReservationNotFoundException()); - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/confirm") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF))); - assertThat(response.getStatus()).isEqualTo(409); - verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1); - } - - @Test - void testConfirmLapsedUsernameHash() - throws UsernameHashNotAvailableException, UsernameReservationNotFoundException, BaseUsernameException { - when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1))) - .thenThrow(new UsernameHashNotAvailableException()); - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/confirm") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF))); - assertThat(response.getStatus()).isEqualTo(410); - verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1); - } - - @Test - void testConfirmUsernameHashInvalidBase64UrlEncoding() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/confirm") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json( - // Has '+' and '='characters which are invalid in base64url - """ - { - "usernameHash": "jh1jJ50oGn9wUXAFNtDus6AJgWOQ6XbZzF+wCv7OOQs=", - "zkProof": "iYXE0QPK60PS3lGa-xdNv0GlXA3B03xQLzltSf-2xmscyS_8fjy5H9ymfaEr62PcVY7tsWhWjOOvcCnhmP_HS=" - } - """)); - assertThat(response.getStatus()).isEqualTo(422); - verifyNoInteractions(usernameZkProofVerifier); - } - - @Test - void testConfirmUsernameHashInvalidHashSize() { - byte[] usernameHash = new byte[31]; - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/confirm") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ConfirmUsernameHashRequest(usernameHash, ZK_PROOF))); - assertThat(response.getStatus()).isEqualTo(422); - verifyNoInteractions(usernameZkProofVerifier); - } - - @Test - void testCommitUsernameHashWithInvalidProof() throws BaseUsernameException { - doThrow(new BaseUsernameException("invalid username")).when(usernameZkProofVerifier).verifyProof(eq(ZK_PROOF), eq(USERNAME_HASH_1)); - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/confirm") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF))); - assertThat(response.getStatus()).isEqualTo(422); - verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1); - } - - @Test - void testDeleteUsername() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .delete(); - - assertThat(response.getStatus()).isEqualTo(204); - verify(accountsManager).clearUsernameHash(AuthHelper.VALID_ACCOUNT); - } - - @Test - void testDeleteUsernameBadAuth() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/username_hash/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) - .delete(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testSetAccountAttributesNoDiscoverabilityChange() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/attributes/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new AccountAttributes(false, 2222, null, null, true, null))); - - assertThat(response.getStatus()).isEqualTo(204); - } - - @Test - void testSetAccountAttributesEnableDiscovery() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/attributes/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.UNDISCOVERABLE_UUID, AuthHelper.UNDISCOVERABLE_PASSWORD)) - .put(Entity.json(new AccountAttributes(false, 2222, null, null, true, null))); - - assertThat(response.getStatus()).isEqualTo(204); - } - - @Test - void testAccountsAttributesUpdateRecoveryPassword() { - final byte[] recoveryPassword = RandomUtils.nextBytes(32); - final Response response = - resources.getJerseyTest() - .target("/v1/accounts/attributes/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.UNDISCOVERABLE_UUID, AuthHelper.UNDISCOVERABLE_PASSWORD)) - .put(Entity.json(new AccountAttributes(false, 2222, null, null, true, null) - .withRecoveryPassword(recoveryPassword))); - - assertThat(response.getStatus()).isEqualTo(204); - verify(registrationRecoveryPasswordsManager).storeForCurrentNumber(eq(AuthHelper.UNDISCOVERABLE_NUMBER), eq(recoveryPassword)); - } - - @Test - void testSetAccountAttributesDisableDiscovery() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/attributes/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new AccountAttributes(false, 2222, null, null, false, null))); - - assertThat(response.getStatus()).isEqualTo(204); - } - - @Test - void testSetAccountAttributesBadUnidentifiedKeyLength() { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/attributes/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new AccountAttributes(false, 2222, null, null, false, null) - .withUnidentifiedAccessKey(new byte[7]))); - - assertThat(response.getStatus()).isEqualTo(422); - } - - @Test - void testDeleteAccount() throws InterruptedException { - Response response = - resources.getJerseyTest() - .target("/v1/accounts/me") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .delete(); - - assertThat(response.getStatus()).isEqualTo(204); - verify(accountsManager).delete(AuthHelper.VALID_ACCOUNT, AccountsManager.DeletionReason.USER_REQUEST); - } - - @Test - void testDeleteAccountInterrupted() throws InterruptedException { - doThrow(InterruptedException.class).when(accountsManager).delete(any(), any()); - - Response response = - resources.getJerseyTest() - .target("/v1/accounts/me") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .delete(); - - assertThat(response.getStatus()).isEqualTo(500); - verify(accountsManager).delete(AuthHelper.VALID_ACCOUNT, AccountsManager.DeletionReason.USER_REQUEST); - } - - @ParameterizedTest - @MethodSource - void testSignupCaptcha(final String message, final boolean enforced, final Set countryCodes, final int expectedResponseStatusCode) { - DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - when(dynamicConfigurationManager.getConfiguration()) - .thenReturn(dynamicConfiguration); - - DynamicCaptchaConfiguration signupCaptchaConfig = new DynamicCaptchaConfiguration(); - signupCaptchaConfig.setSignupCountryCodes(countryCodes); - when(dynamicConfiguration.getCaptchaConfiguration()) - .thenReturn(signupCaptchaConfig); - - final byte[] sessionId = "session".getBytes(StandardCharsets.UTF_8); - - when(registrationServiceClient.sendRegistrationCode(any(), any(), any(), any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - when(registrationServiceClient.createRegistrationSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(sessionId)); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/accounts/sms/code/%s", SENDER)) - .queryParam("challenge", "1234-push") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, NICE_HOST) - .get(); - - assertThat(response.getStatus()).isEqualTo(expectedResponseStatusCode); - - verify(registrationServiceClient, 200 == expectedResponseStatusCode ? times(1) : never()) - .sendRegistrationCode(sessionId, MessageTransport.SMS, ClientType.UNKNOWN, null, AccountController.REGISTRATION_RPC_TIMEOUT); - } - - static Stream testSignupCaptcha() { - return Stream.of( - Arguments.of("captcha not enforced", false, Collections.emptySet(), 200), - Arguments.of("no enforced country codes", true, Collections.emptySet(), 200), - Arguments.of("captcha enforced", true, Set.of("1"), 402) - ); - } - - @Test - void testAccountExists() { - final Account account = mock(Account.class); - - final UUID accountIdentifier = UUID.randomUUID(); - final UUID phoneNumberIdentifier = UUID.randomUUID(); - - when(accountsManager.getByAccountIdentifier(any())).thenReturn(Optional.empty()); - when(accountsManager.getByAccountIdentifier(accountIdentifier)).thenReturn(Optional.of(account)); - when(accountsManager.getByPhoneNumberIdentifier(any())).thenReturn(Optional.empty()); - when(accountsManager.getByPhoneNumberIdentifier(phoneNumberIdentifier)).thenReturn(Optional.of(account)); - - when(rateLimiters.getCheckAccountExistenceLimiter()).thenReturn(mock(RateLimiter.class)); - - assertThat(resources.getJerseyTest() - .target(String.format("/v1/accounts/account/%s", accountIdentifier)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .head() - .getStatus()).isEqualTo(200); - - assertThat(resources.getJerseyTest() - .target(String.format("/v1/accounts/account/%s", phoneNumberIdentifier)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .head() - .getStatus()).isEqualTo(200); - - assertThat(resources.getJerseyTest() - .target(String.format("/v1/accounts/account/%s", UUID.randomUUID())) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .head() - .getStatus()).isEqualTo(404); - } - - @Test - void testAccountExistsRateLimited() throws RateLimitExceededException { - final Duration expectedRetryAfter = Duration.ofSeconds(13); - final Account account = mock(Account.class); - final UUID accountIdentifier = UUID.randomUUID(); - when(accountsManager.getByAccountIdentifier(accountIdentifier)).thenReturn(Optional.of(account)); - - MockUtils.updateRateLimiterResponseToFail( - rateLimiters, RateLimiters.Handle.CHECK_ACCOUNT_EXISTENCE, "127.0.0.1", expectedRetryAfter, true); - - final Response response = resources.getJerseyTest() - .target(String.format("/v1/accounts/account/%s", accountIdentifier)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .head(); - - assertThat(response.getStatus()).isEqualTo(413); - assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(expectedRetryAfter.toSeconds())); - } - - @Test - void testAccountExistsNoForwardedFor() throws RateLimitExceededException { - final Response response = resources.getJerseyTest() - .target(String.format("/v1/accounts/account/%s", UUID.randomUUID())) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "") - .head(); - - assertThat(response.getStatus()).isEqualTo(413); - assertThat(Long.parseLong(response.getHeaderString("Retry-After"))).isNotNegative(); - } - - @Test - void testAccountExistsAuthenticated() { - assertThat(resources.getJerseyTest() - .target(String.format("/v1/accounts/account/%s", UUID.randomUUID())) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .head() - .getStatus()).isEqualTo(400); - } - - @Test - void testLookupUsername() { - final Account account = mock(Account.class); - final UUID uuid = UUID.randomUUID(); - when(account.getUuid()).thenReturn(uuid); - - when(accountsManager.getByUsernameHash(any())).thenReturn(Optional.of(account)); - Response response = resources.getJerseyTest() - .target(String.format("v1/accounts/username_hash/%s", BASE_64_URL_USERNAME_HASH_1)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .get(); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.readEntity(AccountIdentifierResponse.class).uuid()).isEqualTo(uuid); - } - - @Test - void testLookupUsernameDoesNotExist() { - when(accountsManager.getByUsernameHash(any())).thenReturn(Optional.empty()); - assertThat(resources.getJerseyTest() - .target(String.format("v1/accounts/username_hash/%s", BASE_64_URL_USERNAME_HASH_1)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .get().getStatus()).isEqualTo(404); - } - - @Test - void testLookupUsernameRateLimited() throws RateLimitExceededException { - final Duration expectedRetryAfter = Duration.ofSeconds(13); - MockUtils.updateRateLimiterResponseToFail( - rateLimiters, RateLimiters.Handle.USERNAME_LOOKUP, "127.0.0.1", expectedRetryAfter, true); - final Response response = resources.getJerseyTest() - .target(String.format("v1/accounts/username_hash/%s", BASE_64_URL_USERNAME_HASH_1)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .get(); - - assertThat(response.getStatus()).isEqualTo(413); - assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(expectedRetryAfter.toSeconds())); - } - - @Test - void testLookupUsernameAuthenticated() { - assertThat(resources.getJerseyTest() - .target(String.format("/v1/accounts/username_hash/%s", USERNAME_HASH_1)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .get() - .getStatus()).isEqualTo(400); - } - - @Test - void testLookupUsernameInvalidFormat() { - assertThat(resources.getJerseyTest() - .target(String.format("/v1/accounts/username_hash/%s", INVALID_USERNAME_HASH)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .get() - .getStatus()).isEqualTo(422); - - assertThat(resources.getJerseyTest() - .target(String.format("/v1/accounts/username_hash/%s", TOO_SHORT_USERNAME_HASH)) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "127.0.0.1") - .get() - .getStatus()).isEqualTo(422); - } - - @ParameterizedTest - @MethodSource - void pushTokensMatch(@Nullable final String pushChallenge, @Nullable final StoredVerificationCode storedVerificationCode, final boolean expectMatch) { - final String number = "+18005550123"; - final Optional maybePushChallenge = Optional.ofNullable(pushChallenge); - final Optional maybeStoredVerificationCode = Optional.ofNullable(storedVerificationCode); - - assertEquals(expectMatch, AccountController.pushChallengeMatches(number, maybePushChallenge, maybeStoredVerificationCode)); - } - - private static Stream pushTokensMatch() { - return Stream.of( - Arguments.of(null, null, false), - Arguments.of("123456", null, false), - Arguments.of(null, new StoredVerificationCode(null, 0, null, null), false), - Arguments.of(null, new StoredVerificationCode(null, 0, "123456", null), false), - Arguments.of("654321", new StoredVerificationCode(null, 0, "123456", null), false), - Arguments.of("123456", new StoredVerificationCode(null, 0, "123456", null), true) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java deleted file mode 100644 index 41abe473f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java +++ /dev/null @@ -1,441 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.client.Entity; -import javax.ws.rs.client.Invocation; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.apache.http.HttpStatus; -import org.glassfish.jersey.server.ServerProperties; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; -import org.whispersystems.textsecuregcm.auth.RegistrationLockError; -import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; -import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse; -import org.whispersystems.textsecuregcm.entities.ChangeNumberRequest; -import org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest; -import org.whispersystems.textsecuregcm.entities.RegistrationSession; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.ChangeNumberManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class AccountControllerV2Test { - - public static final String NEW_NUMBER = PhoneNumberUtil.getInstance().format( - PhoneNumberUtil.getInstance().getExampleNumber("US"), - PhoneNumberUtil.PhoneNumberFormat.E164); - - private final AccountsManager accountsManager = mock(AccountsManager.class); - private final ChangeNumberManager changeNumberManager = mock(ChangeNumberManager.class); - private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class); - private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock( - RegistrationRecoveryPasswordsManager.class); - private final RegistrationLockVerificationManager registrationLockVerificationManager = mock( - RegistrationLockVerificationManager.class); - private final RateLimiters rateLimiters = mock(RateLimiters.class); - private final RateLimiter registrationLimiter = mock(RateLimiter.class); - - private final ResourceExtension resources = ResourceExtension.builder() - .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) - .addProvider(AuthHelper.getAuthFilter()) - .addProvider( - new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, - DisabledPermittedAuthenticatedAccount.class))) - .addProvider(new RateLimitExceededExceptionMapper()) - .addProvider(new ImpossiblePhoneNumberExceptionMapper()) - .addProvider(new NonNormalizedPhoneNumberExceptionMapper()) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource( - new AccountControllerV2(accountsManager, changeNumberManager, - new PhoneVerificationTokenManager(registrationServiceClient, registrationRecoveryPasswordsManager), - registrationLockVerificationManager, rateLimiters)) - .build(); - - @Nested - class ChangeNumber { - - @BeforeEach - void setUp() throws Exception { - when(rateLimiters.getRegistrationLimiter()).thenReturn(registrationLimiter); - - when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer( - (Answer) invocation -> { - final Account account = invocation.getArgument(0, Account.class); - final String number = invocation.getArgument(1, String.class); - final String pniIdentityKey = invocation.getArgument(2, String.class); - - final UUID uuid = account.getUuid(); - final List devices = account.getDevices(); - - final Account updatedAccount = mock(Account.class); - when(updatedAccount.getUuid()).thenReturn(uuid); - when(updatedAccount.getNumber()).thenReturn(number); - when(updatedAccount.getPhoneNumberIdentityKey()).thenReturn(pniIdentityKey); - when(updatedAccount.getPhoneNumberIdentifier()).thenReturn(UUID.randomUUID()); - when(updatedAccount.getDevices()).thenReturn(devices); - - for (long i = 1; i <= 3; i++) { - final Optional d = account.getDevice(i); - when(updatedAccount.getDevice(i)).thenReturn(d); - } - - return updatedAccount; - }); - } - - @Test - void changeNumberSuccess() throws Exception { - - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NEW_NUMBER, true)))); - - final AccountIdentityResponse accountIdentityResponse = - resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity( - new ChangeNumberRequest(encodeSessionId("session"), null, NEW_NUMBER, "123", "123", - Collections.emptyList(), - Collections.emptyMap(), Collections.emptyMap()), - MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class); - - verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), eq(NEW_NUMBER), any(), any(), any(), - any()); - - assertEquals(AuthHelper.VALID_UUID, accountIdentityResponse.uuid()); - assertEquals(NEW_NUMBER, accountIdentityResponse.number()); - assertNotEquals(AuthHelper.VALID_PNI, accountIdentityResponse.pni()); - } - - @Test - void unprocessableRequestJson() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - try (Response response = request.put(Entity.json(unprocessableJson()))) { - assertEquals(400, response.getStatus()); - } - } - - @Test - void missingBasicAuthorization() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request(); - try (Response response = request.put(Entity.json(requestJson("sessionId", NEW_NUMBER)))) { - assertEquals(401, response.getStatus()); - } - } - - @Test - void invalidBasicAuthorization() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, "Basic but-invalid"); - try (Response response = request.put(Entity.json(invalidRequestJson()))) { - assertEquals(401, response.getStatus()); - } - } - - @Test - void invalidRequestBody() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - try (Response response = request.put(Entity.json(invalidRequestJson()))) { - assertEquals(422, response.getStatus()); - } - } - - @Test - void rateLimitedNumber() throws Exception { - doThrow(new RateLimitExceededException(null, true)) - .when(registrationLimiter).validate(anyString()); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - try (Response response = request.put(Entity.json(requestJson("sessionId", NEW_NUMBER)))) { - assertEquals(429, response.getStatus()); - } - } - - @Test - void registrationServiceTimeout() { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.failedFuture(new RuntimeException())); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - try (Response response = request.put(Entity.json(requestJson("sessionId", NEW_NUMBER)))) { - assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus()); - } - } - - @ParameterizedTest - @MethodSource - void registrationServiceSessionCheck(@Nullable final RegistrationSession session, final int expectedStatus, - final String message) { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(session))); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - try (Response response = request.put(Entity.json(requestJson("sessionId", NEW_NUMBER)))) { - assertEquals(expectedStatus, response.getStatus(), message); - } - } - - static Stream registrationServiceSessionCheck() { - return Stream.of( - Arguments.of(null, 401, "session not found"), - Arguments.of(new RegistrationSession("+18005551234", false), 400, "session number mismatch"), - Arguments.of(new RegistrationSession(NEW_NUMBER, false), 401, "session not verified") - ); - } - - @ParameterizedTest - @EnumSource(RegistrationLockError.class) - void registrationLock(final RegistrationLockError error) throws Exception { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn( - CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NEW_NUMBER, true)))); - - when(accountsManager.getByE164(any())).thenReturn(Optional.of(mock(Account.class))); - - final Exception e = switch (error) { - case MISMATCH -> new WebApplicationException(error.getExpectedStatus()); - case RATE_LIMITED -> new RateLimitExceededException(null, true); - }; - doThrow(e) - .when(registrationLockVerificationManager).verifyRegistrationLock(any(), any()); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - try (Response response = request.put(Entity.json(requestJson("sessionId", NEW_NUMBER)))) { - assertEquals(error.getExpectedStatus(), response.getStatus()); - } - } - - @Test - void recoveryPasswordManagerVerificationTrue() throws Exception { - when(registrationRecoveryPasswordsManager.verify(any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - final byte[] recoveryPassword = new byte[32]; - try (Response response = request.put(Entity.json(requestJsonRecoveryPassword(recoveryPassword, NEW_NUMBER)))) { - assertEquals(200, response.getStatus()); - - final AccountIdentityResponse accountIdentityResponse = response.readEntity(AccountIdentityResponse.class); - - verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), eq(NEW_NUMBER), any(), any(), any(), - any()); - - assertEquals(AuthHelper.VALID_UUID, accountIdentityResponse.uuid()); - assertEquals(NEW_NUMBER, accountIdentityResponse.number()); - assertNotEquals(AuthHelper.VALID_PNI, accountIdentityResponse.pni()); - } - } - - @Test - void recoveryPasswordManagerVerificationFalse() { - when(registrationRecoveryPasswordsManager.verify(any(), any())) - .thenReturn(CompletableFuture.completedFuture(false)); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v2/accounts/number") - .request() - .header(HttpHeaders.AUTHORIZATION, - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)); - try (Response response = request.put(Entity.json(requestJsonRecoveryPassword(new byte[32], NEW_NUMBER)))) { - assertEquals(403, response.getStatus()); - } - } - - /** - * Valid request JSON with the given Recovery Password - */ - private static String requestJsonRecoveryPassword(final byte[] recoveryPassword, final String newNumber) { - return requestJson("", recoveryPassword, newNumber); - } - - /** - * Valid request JSON with the give session ID and recovery password - */ - private static String requestJson(final String sessionId, final byte[] recoveryPassword, final String newNumber) { - return String.format(""" - { - "sessionId": "%s", - "recoveryPassword": "%s", - "number": "%s", - "reglock": "1234", - "pniIdentityKey": "5678", - "deviceMessages": [], - "devicePniSignedPrekeys": {}, - "pniRegistrationIds": {} - } - """, encodeSessionId(sessionId), encodeRecoveryPassword(recoveryPassword), newNumber); - } - - /** - * Valid request JSON with the give session ID - */ - private static String requestJson(final String sessionId, final String newNumber) { - return requestJson(sessionId, new byte[0], newNumber); - } - - /** - * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.ChangeNumberRequest}, but that - * fails validation - */ - private static String invalidRequestJson() { - return """ - { - "sessionId": null - } - """; - } - - /** - * Request JSON that cannot be marshalled into - * {@link org.whispersystems.textsecuregcm.entities.ChangeNumberRequest} - */ - private static String unprocessableJson() { - return """ - { - "sessionId": [] - } - """; - } - - private static String encodeSessionId(final String sessionId) { - return Base64.getUrlEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)); - } - - private static String encodeRecoveryPassword(final byte[] recoveryPassword) { - return Base64.getEncoder().encodeToString(recoveryPassword); - } - } - - @Nested - class PhoneNumberDiscoverability { - - @BeforeEach - void setup() { - AccountsHelper.setupMockUpdate(accountsManager); - } - @Test - void testSetPhoneNumberDiscoverability() { - Response response = resources.getJerseyTest() - .target("/v2/accounts/phone_number_discoverability") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(new PhoneNumberDiscoverabilityRequest(true))); - - assertThat(response.getStatus()).isEqualTo(204); - - ArgumentCaptor discoverabilityCapture = ArgumentCaptor.forClass(Boolean.class); - verify(AuthHelper.VALID_ACCOUNT).setDiscoverableByPhoneNumber(discoverabilityCapture.capture()); - assertThat(discoverabilityCapture.getValue()).isTrue(); - } - - @Test - void testSetNullPhoneNumberDiscoverability() { - Response response = resources.getJerseyTest() - .target("/v2/accounts/phone_number_discoverability") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json( - """ - { - "discoverableByPhoneNumber": null - } - """)); - - assertThat(response.getStatus()).isEqualTo(422); - verify(AuthHelper.VALID_ACCOUNT, never()).setDiscoverableByPhoneNumber(anyBoolean()); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java deleted file mode 100644 index 5cb4244c4..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; - -import com.google.common.net.HttpHeaders; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.io.IOException; -import java.time.Duration; -import java.util.Set; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.push.NotPushRegisteredException; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class ChallengeControllerTest { - - private static final RateLimitChallengeManager rateLimitChallengeManager = mock(RateLimitChallengeManager.class); - - private static final ChallengeController challengeController = new ChallengeController(rateLimitChallengeManager); - - private static final ResourceExtension EXTENSION = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - Set.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new RateLimitExceededExceptionMapper()) - .addResource(challengeController) - .build(); - - @AfterEach - void teardown() { - reset(rateLimitChallengeManager); - } - - @Test - void testHandlePushChallenge() throws RateLimitExceededException { - final String pushChallengeJson = """ - { - "type": "rateLimitPushChallenge", - "challenge": "Hello I am a push challenge token" - } - """; - - final Response response = EXTENSION.target("/v1/challenge") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(pushChallengeJson)); - - assertEquals(200, response.getStatus()); - verify(rateLimitChallengeManager).answerPushChallenge(AuthHelper.VALID_ACCOUNT, "Hello I am a push challenge token"); - } - - @Test - void testHandlePushChallengeRateLimited() throws RateLimitExceededException { - final String pushChallengeJson = """ - { - "type": "rateLimitPushChallenge", - "challenge": "Hello I am a push challenge token" - } - """; - - final Duration retryAfter = Duration.ofMinutes(17); - doThrow(new RateLimitExceededException(retryAfter, true)).when(rateLimitChallengeManager) - .answerPushChallenge(any(), any()); - - final Response response = EXTENSION.target("/v1/challenge") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(pushChallengeJson)); - - assertEquals(413, response.getStatus()); - assertEquals(String.valueOf(retryAfter.toSeconds()), response.getHeaderString("Retry-After")); - } - - @Test - void testHandleRecaptcha() throws RateLimitExceededException, IOException { - final String recaptchaChallengeJson = """ - { - "type": "recaptcha", - "token": "A server-generated token", - "captcha": "The value of the solved captcha token" - } - """; - - final Response response = EXTENSION.target("/v1/challenge") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1") - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(recaptchaChallengeJson)); - - assertEquals(200, response.getStatus()); - verify(rateLimitChallengeManager).answerRecaptchaChallenge(eq(AuthHelper.VALID_ACCOUNT), eq("The value of the solved captcha token"), eq("10.0.0.1"), anyString()); - } - - @Test - void testHandleRecaptchaRateLimited() throws RateLimitExceededException, IOException { - final String recaptchaChallengeJson = """ - { - "type": "recaptcha", - "token": "A server-generated token", - "captcha": "The value of the solved captcha token" - } - """; - - final Duration retryAfter = Duration.ofMinutes(17); - doThrow(new RateLimitExceededException(retryAfter, true)).when(rateLimitChallengeManager) - .answerRecaptchaChallenge(any(), any(), any(), any()); - - final Response response = EXTENSION.target("/v1/challenge") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1") - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(recaptchaChallengeJson)); - - assertEquals(413, response.getStatus()); - assertEquals(String.valueOf(retryAfter.toSeconds()), response.getHeaderString("Retry-After")); - } - - @Test - void testHandleRecaptchaNoForwardedFor() { - final String recaptchaChallengeJson = """ - { - "type": "recaptcha", - "token": "A server-generated token", - "captcha": "The value of the solved captcha token" - } - """; - - final Response response = EXTENSION.target("/v1/challenge") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(recaptchaChallengeJson)); - - assertEquals(400, response.getStatus()); - verifyNoInteractions(rateLimitChallengeManager); - } - - @Test - void testHandleUnrecognizedAnswer() { - final String unrecognizedJson = """ - { - "type": "unrecognized" - } - """; - - final Response response = EXTENSION.target("/v1/challenge") - .request() - .header(HttpHeaders.X_FORWARDED_FOR, "10.0.0.1") - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(unrecognizedJson)); - - assertEquals(400, response.getStatus()); - - verifyNoInteractions(rateLimitChallengeManager); - } - - @Test - void testRequestPushChallenge() throws NotPushRegisteredException { - { - final Response response = EXTENSION.target("/v1/challenge/push") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .post(Entity.text("")); - - assertEquals(200, response.getStatus()); - } - - { - doThrow(NotPushRegisteredException.class).when(rateLimitChallengeManager).sendPushChallenge(AuthHelper.VALID_ACCOUNT_TWO); - - final Response response = EXTENSION.target("/v1/challenge/push") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .post(Entity.text("")); - - assertEquals(404, response.getStatus()); - } - } - - @Test - void testValidationError() { - final String unrecognizedJson = """ - { - "type": "rateLimitPushChallenge" - } - """; - - final Response response = EXTENSION.target("/v1/challenge") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json(unrecognizedJson)); - - assertEquals(422, response.getStatus()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java deleted file mode 100644 index a96c8c221..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java +++ /dev/null @@ -1,1207 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.not; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.anyBoolean; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.asJson; -import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture; - -import com.google.common.collect.ImmutableSet; -import com.google.protobuf.ByteString; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.util.Arrays; -import java.util.Base64; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.Callable; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; -import javax.ws.rs.client.Entity; -import javax.ws.rs.client.Invocation; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.server.ServerProperties; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.hamcrest.CoreMatchers; -import org.hamcrest.Matcher; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.entities.IncomingMessage; -import org.whispersystems.textsecuregcm.entities.IncomingMessageList; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import org.whispersystems.textsecuregcm.entities.MismatchedDevices; -import org.whispersystems.textsecuregcm.entities.MultiRecipientMessage; -import org.whispersystems.textsecuregcm.entities.MultiRecipientMessage.Recipient; -import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity; -import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList; -import org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.entities.SpamReport; -import org.whispersystems.textsecuregcm.entities.StaleDevices; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider; -import org.whispersystems.textsecuregcm.push.MessageSender; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.spam.ReportSpamTokenProvider; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.websocket.Stories; -import reactor.core.publisher.Mono; - -@ExtendWith(DropwizardExtensionsSupport.class) -class MessageControllerTest { - - private static final String SINGLE_DEVICE_RECIPIENT = "+14151111111"; - private static final UUID SINGLE_DEVICE_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111"); - private static final UUID SINGLE_DEVICE_PNI = UUID.fromString("11111111-0000-0000-0000-111111111111"); - private static final int SINGLE_DEVICE_ID1 = 1; - private static final int SINGLE_DEVICE_REG_ID1 = 111; - - private static final String MULTI_DEVICE_RECIPIENT = "+14152222222"; - private static final UUID MULTI_DEVICE_UUID = UUID.fromString("22222222-2222-2222-2222-222222222222"); - private static final UUID MULTI_DEVICE_PNI = UUID.fromString("22222222-0000-0000-0000-222222222222"); - private static final int MULTI_DEVICE_ID1 = 1; - private static final int MULTI_DEVICE_ID2 = 2; - private static final int MULTI_DEVICE_ID3 = 3; - private static final int MULTI_DEVICE_REG_ID1 = 222; - private static final int MULTI_DEVICE_REG_ID2 = 333; - private static final int MULTI_DEVICE_REG_ID3 = 444; - - private static final byte[] UNIDENTIFIED_ACCESS_BYTES = "0123456789abcdef".getBytes(); - - private static final String INTERNATIONAL_RECIPIENT = "+61123456789"; - private static final UUID INTERNATIONAL_UUID = UUID.fromString("33333333-3333-3333-3333-333333333333"); - - @SuppressWarnings("unchecked") - private static final RedisAdvancedClusterCommands redisCommands = mock(RedisAdvancedClusterCommands.class); - - private static final MessageSender messageSender = mock(MessageSender.class); - private static final ReceiptSender receiptSender = mock(ReceiptSender.class); - private static final AccountsManager accountsManager = mock(AccountsManager.class); - private static final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class); - private static final MessagesManager messagesManager = mock(MessagesManager.class); - private static final RateLimiters rateLimiters = mock(RateLimiters.class); - private static final RateLimiter rateLimiter = mock(RateLimiter.class); - private static final PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class); - private static final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class); - private static final ExecutorService multiRecipientMessageExecutor = mock(ExecutorService.class); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .addProvider(RateLimitExceededExceptionMapper.class) - .addProvider(MultiRecipientMessageProvider.class) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource( - new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, - messagesManager, pushNotificationManager, reportMessageManager, multiRecipientMessageExecutor, - ReportSpamTokenProvider.noop())) - .build(); - - @BeforeEach - void setup() { - final List singleDeviceList = List.of( - generateTestDevice(SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, 1111, new SignedPreKey(333, "baz", "boop"), System.currentTimeMillis(), System.currentTimeMillis()) - ); - - final List multiDeviceList = List.of( - generateTestDevice(MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1, 2222, new SignedPreKey(111, "foo", "bar"), System.currentTimeMillis(), System.currentTimeMillis()), - generateTestDevice(MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2, 3333, new SignedPreKey(222, "oof", "rab"), System.currentTimeMillis(), System.currentTimeMillis()), - generateTestDevice(MULTI_DEVICE_ID3, MULTI_DEVICE_REG_ID3, 4444, null, System.currentTimeMillis(), System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)) - ); - - Account singleDeviceAccount = AccountsHelper.generateTestAccount(SINGLE_DEVICE_RECIPIENT, SINGLE_DEVICE_UUID, SINGLE_DEVICE_PNI, singleDeviceList, UNIDENTIFIED_ACCESS_BYTES); - Account multiDeviceAccount = AccountsHelper.generateTestAccount(MULTI_DEVICE_RECIPIENT, MULTI_DEVICE_UUID, MULTI_DEVICE_PNI, multiDeviceList, UNIDENTIFIED_ACCESS_BYTES); - Account internationalAccount = AccountsHelper.generateTestAccount(INTERNATIONAL_RECIPIENT, INTERNATIONAL_UUID, - UUID.randomUUID(), singleDeviceList, UNIDENTIFIED_ACCESS_BYTES); - - when(accountsManager.getByAccountIdentifier(eq(SINGLE_DEVICE_UUID))).thenReturn(Optional.of(singleDeviceAccount)); - when(accountsManager.getByPhoneNumberIdentifier(SINGLE_DEVICE_PNI)).thenReturn(Optional.of(singleDeviceAccount)); - when(accountsManager.getByAccountIdentifier(eq(MULTI_DEVICE_UUID))).thenReturn(Optional.of(multiDeviceAccount)); - when(accountsManager.getByPhoneNumberIdentifier(MULTI_DEVICE_PNI)).thenReturn(Optional.of(multiDeviceAccount)); - when(accountsManager.getByAccountIdentifier(INTERNATIONAL_UUID)).thenReturn(Optional.of(internationalAccount)); - - when(rateLimiters.getMessagesLimiter()).thenReturn(rateLimiter); - } - - private static Device generateTestDevice(final long id, final int registrationId, final int pniRegistrationId, final SignedPreKey signedPreKey, final long createdAt, final long lastSeen) { - final Device device = new Device(); - device.setId(id); - device.setRegistrationId(registrationId); - device.setPhoneNumberIdentityRegistrationId(pniRegistrationId); - device.setSignedPreKey(signedPreKey); - device.setCreated(createdAt); - device.setLastSeen(lastSeen); - device.setGcmId("isgcm"); - - return device; - } - - @AfterEach - void teardown() { - reset( - redisCommands, - messageSender, - receiptSender, - accountsManager, - deletedAccountsManager, - messagesManager, - rateLimiters, - rateLimiter, - pushNotificationManager, - reportMessageManager, - multiRecipientMessageExecutor - ); - } - - @Test - void testSendFromDisabledAccount() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Unauthorized response", response.getStatus(), is(equalTo(401))); - } - - @Test - void testSingleDeviceCurrent() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response", response.getStatus(), is(equalTo(200))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Envelope.class); - verify(messageSender, times(1)).sendMessage(any(Account.class), any(Device.class), captor.capture(), eq(false)); - - assertTrue(captor.getValue().hasSourceUuid()); - assertTrue(captor.getValue().hasSourceDevice()); - assertTrue(captor.getValue().getUrgent()); - } - - @Test - void testSingleDeviceCurrentNotUrgent() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device_not_urgent.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response", response.getStatus(), is(equalTo(200))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Envelope.class); - verify(messageSender, times(1)).sendMessage(any(Account.class), any(Device.class), captor.capture(), eq(false)); - - assertTrue(captor.getValue().hasSourceUuid()); - assertTrue(captor.getValue().hasSourceDevice()); - assertFalse(captor.getValue().getUrgent()); - } - - @Test - void testSingleDeviceCurrentByPni() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_PNI)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response", response.getStatus(), is(equalTo(200))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Envelope.class); - verify(messageSender, times(1)).sendMessage(any(Account.class), any(Device.class), captor.capture(), eq(false)); - - assertTrue(captor.getValue().hasSourceUuid()); - assertTrue(captor.getValue().hasSourceDevice()); - } - - @Test - void testNullMessageInList() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_null_message_in_list.json"), IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Bad request", response.getStatus(), is(equalTo(422))); - } - - @Test - void testSingleDeviceCurrentUnidentified() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response", response.getStatus(), is(equalTo(200))); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Envelope.class); - verify(messageSender, times(1)).sendMessage(any(Account.class), any(Device.class), captor.capture(), eq(false)); - - assertFalse(captor.getValue().hasSourceUuid()); - assertFalse(captor.getValue().hasSourceDevice()); - } - - @Test - void testSendBadAuth() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response", response.getStatus(), is(equalTo(401))); - } - - @Test - void testMultiDeviceMissing() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", MULTI_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_single_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(409))); - - assertThat("Good Response Body", - asJson(response.readEntity(MismatchedDevices.class)), - is(equalTo(jsonFixture("fixtures/missing_device_response.json")))); - - verifyNoMoreInteractions(messageSender); - } - - @Test - void testMultiDeviceExtra() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", MULTI_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_extra_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(409))); - - assertThat("Good Response Body", - asJson(response.readEntity(MismatchedDevices.class)), - is(equalTo(jsonFixture("fixtures/missing_device_response2.json")))); - - verifyNoMoreInteractions(messageSender); - } - - @Test - void testMultiDevice() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", MULTI_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_multi_device.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(200))); - - final ArgumentCaptor envelopeCaptor = ArgumentCaptor.forClass(Envelope.class); - - verify(messageSender, times(2)).sendMessage(any(Account.class), any(Device.class), envelopeCaptor.capture(), eq(false)); - - envelopeCaptor.getAllValues().forEach(envelope -> assertTrue(envelope.getUrgent())); - } - - @Test - void testMultiDeviceNotUrgent() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", MULTI_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_multi_device_not_urgent.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(200))); - - final ArgumentCaptor envelopeCaptor = ArgumentCaptor.forClass(Envelope.class); - - verify(messageSender, times(2)).sendMessage(any(Account.class), any(Device.class), envelopeCaptor.capture(), eq(false)); - - envelopeCaptor.getAllValues().forEach(envelope -> assertFalse(envelope.getUrgent())); - } - - @Test - void testMultiDeviceByPni() throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", MULTI_DEVICE_PNI)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_multi_device_pni.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(200))); - - verify(messageSender, times(2)).sendMessage(any(Account.class), any(Device.class), any(Envelope.class), eq(false)); - } - - @Test - void testRegistrationIdMismatch() throws Exception { - Response response = - resources.getJerseyTest().target(String.format("/v1/messages/%s", MULTI_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture("fixtures/current_message_registration_id.json"), - IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(410))); - - assertThat("Good Response Body", - asJson(response.readEntity(StaleDevices.class)), - is(equalTo(jsonFixture("fixtures/mismatched_registration_id.json")))); - - verifyNoMoreInteractions(messageSender); - } - - @ParameterizedTest - @MethodSource - void testGetMessages(boolean receiveStories) { - - final long timestampOne = 313377; - final long timestampTwo = 313388; - - final UUID messageGuidOne = UUID.randomUUID(); - final UUID messageGuidTwo = UUID.randomUUID(); - final UUID sourceUuid = UUID.randomUUID(); - - final UUID updatedPniOne = UUID.randomUUID(); - - List envelopes = List.of( - generateEnvelope(messageGuidOne, Envelope.Type.CIPHERTEXT_VALUE, timestampOne, sourceUuid, 2, - AuthHelper.VALID_UUID, updatedPniOne, "hi there".getBytes(), 0, false), - generateEnvelope(messageGuidTwo, Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE, timestampTwo, sourceUuid, 2, - AuthHelper.VALID_UUID, null, null, 0, true) - ); - - when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(1L), anyBoolean())) - .thenReturn(Mono.just(new Pair<>(envelopes, false))); - - final String userAgent = "Test-UA"; - - OutgoingMessageEntityList response = - resources.getJerseyTest().target("/v1/messages/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(Stories.X_SIGNAL_RECEIVE_STORIES, receiveStories ? "true" : "false") - .header(HttpHeaders.USER_AGENT, userAgent) - .accept(MediaType.APPLICATION_JSON_TYPE) - .get(OutgoingMessageEntityList.class); - - List messages = response.messages(); - int expectedSize = receiveStories ? 2 : 1; - assertEquals(expectedSize, messages.size()); - - OutgoingMessageEntity first = messages.get(0); - assertEquals(first.timestamp(), timestampOne); - assertEquals(first.guid(), messageGuidOne); - assertEquals(first.sourceUuid(), sourceUuid); - assertEquals(updatedPniOne, first.updatedPni()); - - if (receiveStories) { - OutgoingMessageEntity second = messages.get(1); - assertEquals(second.timestamp(), timestampTwo); - assertEquals(second.guid(), messageGuidTwo); - assertEquals(second.sourceUuid(), sourceUuid); - assertNull(second.updatedPni()); - } - - verify(pushNotificationManager).handleMessagesRetrieved(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE, userAgent); - } - - private static Stream testGetMessages() { - return Stream.of( - Arguments.of(true), - Arguments.of(false) - ); - } - - @Test - void testGetMessagesBadAuth() { - final long timestampOne = 313377; - final long timestampTwo = 313388; - - final List messages = List.of( - generateEnvelope(UUID.randomUUID(), Envelope.Type.CIPHERTEXT_VALUE, timestampOne, UUID.randomUUID(), 2, - AuthHelper.VALID_UUID, null, "hi there".getBytes(), 0), - generateEnvelope(UUID.randomUUID(), Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE, timestampTwo, - UUID.randomUUID(), 2, AuthHelper.VALID_UUID, null, null, 0) - ); - - when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(1L), anyBoolean())) - .thenReturn(Mono.just(new Pair<>(messages, false))); - - Response response = - resources.getJerseyTest().target("/v1/messages/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) - .accept(MediaType.APPLICATION_JSON_TYPE) - .get(); - - assertThat("Unauthorized response", response.getStatus(), is(equalTo(401))); - } - - @Test - void testDeleteMessages() { - long timestamp = System.currentTimeMillis(); - - UUID sourceUuid = UUID.randomUUID(); - - UUID uuid1 = UUID.randomUUID(); - when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid1, null)) - .thenReturn( - CompletableFuture.completedFuture(Optional.of(generateEnvelope(uuid1, Envelope.Type.CIPHERTEXT_VALUE, - timestamp, sourceUuid, 1, AuthHelper.VALID_UUID, null, "hi".getBytes(), 0)))); - - UUID uuid2 = UUID.randomUUID(); - when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid2, null)) - .thenReturn( - CompletableFuture.completedFuture(Optional.of(generateEnvelope( - uuid2, Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE, - System.currentTimeMillis(), sourceUuid, 1, AuthHelper.VALID_UUID, null, null, 0)))); - - UUID uuid3 = UUID.randomUUID(); - when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid3, null)) - .thenReturn(CompletableFuture.completedFuture(Optional.empty())); - - UUID uuid4 = UUID.randomUUID(); - when(messagesManager.delete(AuthHelper.VALID_UUID, 1, uuid4, null)) - .thenReturn(CompletableFuture.failedFuture(new RuntimeException("Oh No"))); - - Response response = resources.getJerseyTest() - .target(String.format("/v1/messages/uuid/%s", uuid1)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .delete(); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(204))); - verify(receiptSender).sendReceipt(eq(AuthHelper.VALID_UUID), eq(1L), - eq(sourceUuid), eq(timestamp)); - - response = resources.getJerseyTest() - .target(String.format("/v1/messages/uuid/%s", uuid2)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .delete(); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(204))); - verifyNoMoreInteractions(receiptSender); - - response = resources.getJerseyTest() - .target(String.format("/v1/messages/uuid/%s", uuid3)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .delete(); - - assertThat("Good Response Code", response.getStatus(), is(equalTo(204))); - verifyNoMoreInteractions(receiptSender); - - response = resources.getJerseyTest() - .target(String.format("/v1/messages/uuid/%s", uuid4)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .delete(); - - assertThat("Bad Response Code", response.getStatus(), is(equalTo(500))); - verifyNoMoreInteractions(receiptSender); - - } - - @Test - void testReportMessageByE164() { - - final String senderNumber = "+12125550001"; - final UUID senderAci = UUID.randomUUID(); - final UUID senderPni = UUID.randomUUID(); - final String userAgent = "user-agent"; - UUID messageGuid = UUID.randomUUID(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(senderAci); - when(account.getNumber()).thenReturn(senderNumber); - when(account.getPhoneNumberIdentifier()).thenReturn(senderPni); - - when(accountsManager.getByE164(senderNumber)).thenReturn(Optional.of(account)); - when(deletedAccountsManager.findDeletedAccountAci(senderNumber)).thenReturn(Optional.of(senderAci)); - when(accountsManager.getPhoneNumberIdentifier(senderNumber)).thenReturn(senderPni); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/report/%s/%s", senderNumber, messageGuid)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, userAgent) - .post(null); - - assertThat(response.getStatus(), is(equalTo(202))); - - verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), - messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent); - verify(deletedAccountsManager, never()).findDeletedAccountE164(any(UUID.class)); - verify(accountsManager, never()).getPhoneNumberIdentifier(anyString()); - - when(accountsManager.getByE164(senderNumber)).thenReturn(Optional.empty()); - messageGuid = UUID.randomUUID(); - - response = - resources.getJerseyTest() - .target(String.format("/v1/messages/report/%s/%s", senderNumber, messageGuid)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, userAgent) - .post(null); - - assertThat(response.getStatus(), is(equalTo(202))); - - verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), - messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent); - } - - @Test - void testReportMessageByAci() { - - final String senderNumber = "+12125550001"; - final UUID senderAci = UUID.randomUUID(); - final UUID senderPni = UUID.randomUUID(); - final String userAgent = "user-agent"; - UUID messageGuid = UUID.randomUUID(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(senderAci); - when(account.getNumber()).thenReturn(senderNumber); - when(account.getPhoneNumberIdentifier()).thenReturn(senderPni); - - when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.of(account)); - when(deletedAccountsManager.findDeletedAccountE164(senderAci)).thenReturn(Optional.of(senderNumber)); - when(accountsManager.getPhoneNumberIdentifier(senderNumber)).thenReturn(senderPni); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/report/%s/%s", senderAci, messageGuid)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, userAgent) - .post(null); - - assertThat(response.getStatus(), is(equalTo(202))); - - verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), - messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent); - verify(deletedAccountsManager, never()).findDeletedAccountE164(any(UUID.class)); - verify(accountsManager, never()).getPhoneNumberIdentifier(anyString()); - - when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.empty()); - - messageGuid = UUID.randomUUID(); - - response = - resources.getJerseyTest() - .target(String.format("/v1/messages/report/%s/%s", senderAci, messageGuid)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, userAgent) - .post(null); - - assertThat(response.getStatus(), is(equalTo(202))); - - verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), - messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent); - } - - @Test - void testReportMessageByAciWithSpamReportToken() { - - final String senderNumber = "+12125550001"; - final UUID senderAci = UUID.randomUUID(); - final UUID senderPni = UUID.randomUUID(); - UUID messageGuid = UUID.randomUUID(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(senderAci); - when(account.getNumber()).thenReturn(senderNumber); - when(account.getPhoneNumberIdentifier()).thenReturn(senderPni); - - when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.of(account)); - when(deletedAccountsManager.findDeletedAccountE164(senderAci)).thenReturn(Optional.of(senderNumber)); - when(accountsManager.getPhoneNumberIdentifier(senderNumber)).thenReturn(senderPni); - - Entity entity = Entity.entity(new SpamReport(new byte[3]), "application/json"); - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/report/%s/%s", senderAci, messageGuid)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .post(entity); - - assertThat(response.getStatus(), is(equalTo(202))); - verify(reportMessageManager).report(eq(Optional.of(senderNumber)), - eq(Optional.of(senderAci)), - eq(Optional.of(senderPni)), - eq(messageGuid), - eq(AuthHelper.VALID_UUID), - argThat(maybeBytes -> maybeBytes.map(bytes -> Arrays.equals(bytes, new byte[3])).orElse(false)), - any()); - verify(deletedAccountsManager, never()).findDeletedAccountE164(any(UUID.class)); - verify(accountsManager, never()).getPhoneNumberIdentifier(anyString()); - when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.empty()); - - messageGuid = UUID.randomUUID(); - - entity = Entity.entity(new SpamReport(new byte[5]), "application/json"); - response = - resources.getJerseyTest() - .target(String.format("/v1/messages/report/%s/%s", senderAci, messageGuid)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .post(entity); - - assertThat(response.getStatus(), is(equalTo(202))); - verify(reportMessageManager).report(eq(Optional.of(senderNumber)), - eq(Optional.of(senderAci)), - eq(Optional.of(senderPni)), - eq(messageGuid), - eq(AuthHelper.VALID_UUID), - argThat(maybeBytes -> maybeBytes.map(bytes -> Arrays.equals(bytes, new byte[5])).orElse(false)), - any()); - } - - @ParameterizedTest - @MethodSource - void testReportMessageByAciWithNullSpamReportToken(Entity entity, boolean expectOk) { - - final String senderNumber = "+12125550001"; - final UUID senderAci = UUID.randomUUID(); - final UUID senderPni = UUID.randomUUID(); - UUID messageGuid = UUID.randomUUID(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(senderAci); - when(account.getNumber()).thenReturn(senderNumber); - when(account.getPhoneNumberIdentifier()).thenReturn(senderPni); - - when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.of(account)); - when(deletedAccountsManager.findDeletedAccountE164(senderAci)).thenReturn(Optional.of(senderNumber)); - when(accountsManager.getPhoneNumberIdentifier(senderNumber)).thenReturn(senderPni); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/report/%s/%s", senderAci, messageGuid)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .post(entity); - - Matcher matcher = expectOk ? is(equalTo(202)) : not(equalTo(202)); - assertThat(response.getStatus(), matcher); - } - - private static Stream testReportMessageByAciWithNullSpamReportToken() { - return Stream.of( - Arguments.of(Entity.json(new SpamReport(new byte[5])), true), - Arguments.of(Entity.json("{\"token\":\"AAAAAAA\"}"), true), - Arguments.of(Entity.json(new SpamReport(new byte[0])), true), - Arguments.of(Entity.json(new SpamReport(null)), true), - Arguments.of(Entity.json("{\"token\": \"\"}"), true), - Arguments.of(Entity.json("{\"token\": null}"), true), - Arguments.of(Entity.json("null"), true), - Arguments.of(Entity.json("{\"weird\": 123}"), true), - Arguments.of(Entity.json("\"weirder\""), false), - Arguments.of(Entity.json("weirdest"), false) - ); - } - - @Test - void testValidateContentLength() throws Exception { - final int contentLength = Math.toIntExact(MessageController.MAX_MESSAGE_SIZE + 1); - final byte[] contentBytes = new byte[contentLength]; - Arrays.fill(contentBytes, (byte) 1); - - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .header(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES)) - .put(Entity.entity(new IncomingMessageList( - List.of(new IncomingMessage(1, 1L, 1, new String(contentBytes))), false, true, - System.currentTimeMillis()), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat("Bad response", response.getStatus(), is(equalTo(413))); - - verify(messageSender, never()).sendMessage(any(Account.class), any(Device.class), any(Envelope.class), - anyBoolean()); - } - - @ParameterizedTest - @MethodSource - void testValidateEnvelopeType(String payloadFilename, boolean expectOk) throws Exception { - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", SINGLE_DEVICE_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Test-UA") - .put(Entity.entity(SystemMapper.getMapper().readValue(jsonFixture(payloadFilename), IncomingMessageList.class), - MediaType.APPLICATION_JSON_TYPE)); - - if (expectOk) { - assertEquals(200, response.getStatus()); - - final ArgumentCaptor captor = ArgumentCaptor.forClass(Envelope.class); - verify(messageSender).sendMessage(any(Account.class), any(Device.class), captor.capture(), eq(false)); - } else { - assertEquals(400, response.getStatus()); - verify(messageSender, never()).sendMessage(any(), any(), any(), anyBoolean()); - } - } - - private static Stream testValidateEnvelopeType() { - return Stream.of( - Arguments.of("fixtures/current_message_single_device.json", true), - Arguments.of("fixtures/current_message_single_device_server_receipt_type.json", false) - ); - } - - private static void writePayloadDeviceId(ByteBuffer bb, long deviceId) { - long x = deviceId; - // write the device-id in the 7-bit varint format we use, least significant bytes first. - do { - long b = x & 0x7f; - x = x >>> 7; - if (x != 0) b |= 0x80; - bb.put((byte)b); - } while (x != 0); - } - - private static void writeMultiPayloadRecipient(ByteBuffer bb, Recipient r) throws Exception { - long msb = r.getUuid().getMostSignificantBits(); - long lsb = r.getUuid().getLeastSignificantBits(); - bb.putLong(msb); // uuid (first 8 bytes) - bb.putLong(lsb); // uuid (last 8 bytes) - writePayloadDeviceId(bb, r.getDeviceId()); // device id (1-9 bytes) - bb.putShort((short) r.getRegistrationId()); // registration id (2 bytes) - bb.put(r.getPerRecipientKeyMaterial()); // key material (48 bytes) - } - - private static InputStream initializeMultiPayload(List recipients, byte[] buffer) throws Exception { - // initialize a binary payload according to our wire format - ByteBuffer bb = ByteBuffer.wrap(buffer); - bb.order(ByteOrder.BIG_ENDIAN); - - // first write the header - bb.put(MultiRecipientMessageProvider.VERSION); // version byte - bb.put((byte)recipients.size()); // count varint - - Iterator it = recipients.iterator(); - while (it.hasNext()) { - writeMultiPayloadRecipient(bb, it.next()); - } - - // now write the actual message body (empty for now) - bb.put(new byte[39]); // payload (variable but >= 32, 39 bytes here) - - // return the input stream - return new ByteArrayInputStream(buffer, 0, bb.position()); - } - - @ParameterizedTest - @MethodSource - void testMultiRecipientMessage(UUID recipientUUID, boolean authorize, boolean isStory, boolean urgent) throws Exception { - - final List recipients; - if (recipientUUID == MULTI_DEVICE_UUID) { - recipients = List.of( - new Recipient(MULTI_DEVICE_UUID, MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1, new byte[48]), - new Recipient(MULTI_DEVICE_UUID, MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2, new byte[48]) - ); - } else { - recipients = List.of(new Recipient(SINGLE_DEVICE_UUID, SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, new byte[48])); - } - - // initialize our binary payload and create an input stream - byte[] buffer = new byte[2048]; - //InputStream stream = initializeMultiPayload(recipientUUID, buffer); - InputStream stream = initializeMultiPayload(recipients, buffer); - - // set up the entity to use in our PUT request - Entity entity = Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE); - - when(multiRecipientMessageExecutor.invokeAll(any())) - .thenAnswer(answer -> { - final List tasks = answer.getArgument(0, List.class); - tasks.forEach(c -> { - try { - c.call(); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - return null; - }); - - // start building the request - Invocation.Builder bldr = resources - .getJerseyTest() - .target("/v1/messages/multi_recipient") - .queryParam("online", true) - .queryParam("ts", 1663798405641L) - .queryParam("story", isStory) - .queryParam("urgent", urgent) - .request() - .header(HttpHeaders.USER_AGENT, "FIXME"); - - // add access header if needed - if (authorize) { - String encodedBytes = Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES); - bldr = bldr.header(OptionalAccess.UNIDENTIFIED, encodedBytes); - } - - // make the PUT request - Response response = bldr.put(entity); - - if (authorize) { - ArgumentCaptor envelopeArgumentCaptor = ArgumentCaptor.forClass(Envelope.class); - verify(messageSender, atLeastOnce()).sendMessage(any(), any(), envelopeArgumentCaptor.capture(), anyBoolean()); - assertEquals(urgent, envelopeArgumentCaptor.getValue().getUrgent()); - } - - // We have a 2x2x2 grid of possible situations based on: - // - recipient enabled stories? - // - sender is authorized? - // - message is a story? - // - // (urgent is not included in the grid because it has no effect - // on any of the other settings.) - - if (recipientUUID == MULTI_DEVICE_UUID) { - // This is the case where the recipient has enabled stories. - if(isStory) { - // We are sending a story, so we ignore access checks and expect this - // to go out to both the recipient's devices. - checkGoodMultiRecipientResponse(response, 2); - } else { - // We are not sending a story, so we need to do access checks. - if (authorize) { - // When authorized we send a message to the recipient's devices. - checkGoodMultiRecipientResponse(response, 2); - } else { - // When forbidden, we return a 401 error. - checkBadMultiRecipientResponse(response, 401); - } - } - } else { - // This is the case where the recipient has not enabled stories. - if (isStory) { - // We are sending a story, so we ignore access checks. - // this recipient has one device. - checkGoodMultiRecipientResponse(response, 1); - } else { - // We are not sending a story so check access. - if (authorize) { - // If allowed, send a message to the recipient's one device. - checkGoodMultiRecipientResponse(response, 1); - } else { - // If forbidden, return a 401 error. - checkBadMultiRecipientResponse(response, 401); - } - } - } - } - - // Arguments here are: recipient-UUID, is-authorized?, is-story? - private static Stream testMultiRecipientMessage() { - return Stream.of( - Arguments.of(MULTI_DEVICE_UUID, false, true, true), - Arguments.of(MULTI_DEVICE_UUID, false, false, true), - Arguments.of(SINGLE_DEVICE_UUID, false, true, true), - Arguments.of(SINGLE_DEVICE_UUID, false, false, true), - Arguments.of(MULTI_DEVICE_UUID, true, true, true), - Arguments.of(MULTI_DEVICE_UUID, true, false, true), - Arguments.of(SINGLE_DEVICE_UUID, true, true, true), - Arguments.of(SINGLE_DEVICE_UUID, true, false, true), - Arguments.of(MULTI_DEVICE_UUID, false, true, false), - Arguments.of(MULTI_DEVICE_UUID, false, false, false), - Arguments.of(SINGLE_DEVICE_UUID, false, true, false), - Arguments.of(SINGLE_DEVICE_UUID, false, false, false), - Arguments.of(MULTI_DEVICE_UUID, true, true, false), - Arguments.of(MULTI_DEVICE_UUID, true, false, false), - Arguments.of(SINGLE_DEVICE_UUID, true, true, false), - Arguments.of(SINGLE_DEVICE_UUID, true, false, false) - ); - } - - @Test - void testSendStoryToUnknownAccount() throws Exception { - String accessBytes = Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES); - String json = jsonFixture("fixtures/current_message_single_device.json"); - UUID unknownUUID = UUID.randomUUID(); - IncomingMessageList list = SystemMapper.getMapper().readValue(json, IncomingMessageList.class); - Response response = - resources.getJerseyTest() - .target(String.format("/v1/messages/%s", unknownUUID)) - .queryParam("story", "true") - .request() - .header(OptionalAccess.UNIDENTIFIED, accessBytes) - .put(Entity.entity(list, MediaType.APPLICATION_JSON_TYPE)); - - assertThat("200 masks unknown recipient", response.getStatus(), is(equalTo(200))); - } - - @ParameterizedTest - @MethodSource - void testSendMultiRecipientMessageToUnknownAccounts(boolean story, boolean known) throws Exception { - - final Recipient r1; - if (known) { - r1 = new Recipient(SINGLE_DEVICE_UUID, SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, new byte[48]); - } else { - r1 = new Recipient(UUID.randomUUID(), 999, 999, new byte[48]); - } - - Recipient r2 = new Recipient(MULTI_DEVICE_UUID, MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1, new byte[48]); - Recipient r3 = new Recipient(MULTI_DEVICE_UUID, MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2, new byte[48]); - - List recipients = List.of(r1, r2, r3); - - byte[] buffer = new byte[2048]; - InputStream stream = initializeMultiPayload(recipients, buffer); - // set up the entity to use in our PUT request - Entity entity = Entity.entity(stream, MultiRecipientMessageProvider.MEDIA_TYPE); - - // This looks weird, but there is a method to the madness. - // new bytes[16] is equivalent to UNIDENTIFIED_ACCESS_BYTES ^ UNIDENTIFIED_ACCESS_BYTES - // (i.e. we need to XOR all the access keys together) - String accessBytes = Base64.getEncoder().encodeToString(new byte[16]); - - // start building the request - Invocation.Builder bldr = resources - .getJerseyTest() - .target("/v1/messages/multi_recipient") - .queryParam("online", true) - .queryParam("ts", 1663798405641L) - .queryParam("story", story) - .request() - .header(HttpHeaders.USER_AGENT, "Test User Agent") - .header(OptionalAccess.UNIDENTIFIED, accessBytes); - - // make the PUT request - Response response = bldr.put(entity); - - if (story || known) { - // it's a story so we unconditionally get 200 ok - assertEquals(200, response.getStatus()); - } else { - // unknown recipient means 404 not found - assertEquals(404, response.getStatus()); - } - } - - private static Stream testSendMultiRecipientMessageToUnknownAccounts() { - return Stream.of( - Arguments.of(true, true), - Arguments.of(true, false), - Arguments.of(false, true), - Arguments.of(false, false)); - } - - private void checkBadMultiRecipientResponse(Response response, int expectedCode) throws Exception { - assertThat("Unexpected response", response.getStatus(), is(equalTo(expectedCode))); - verify(messageSender, never()).sendMessage(any(), any(), any(), anyBoolean()); - verify(multiRecipientMessageExecutor, never()).invokeAll(any()); - } - - private void checkGoodMultiRecipientResponse(Response response, int expectedCount) throws Exception { - assertThat("Unexpected response", response.getStatus(), is(equalTo(200))); - ArgumentCaptor>> captor = ArgumentCaptor.forClass(List.class); - verify(multiRecipientMessageExecutor, times(1)).invokeAll(captor.capture()); - assert (captor.getValue().size() == expectedCount); - SendMultiRecipientMessageResponse smrmr = response.readEntity(SendMultiRecipientMessageResponse.class); - assert (smrmr.getUUIDs404().isEmpty()); - } - - private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid, - int sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp) { - return generateEnvelope(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni, content, serverTimestamp, false); - } - - private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid, - int sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp, boolean story) { - - final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder() - .setType(MessageProtos.Envelope.Type.forNumber(type)) - .setTimestamp(timestamp) - .setServerTimestamp(serverTimestamp) - .setDestinationUuid(destinationUuid.toString()) - .setStory(story) - .setServerGuid(guid.toString()); - - if (sourceUuid != null) { - builder.setSourceUuid(sourceUuid.toString()); - builder.setSourceDevice(sourceDevice); - } - - if (content != null) { - builder.setContent(ByteString.copyFrom(content)); - } - - if (updatedPni != null) { - builder.setUpdatedPni(updatedPni.toString()); - } - - return builder.build(); - } - - private static Recipient genRecipient(Random rng) { - UUID u1 = UUID.randomUUID(); // non-null - long d1 = rng.nextLong() & 0x3fffffffffffffffL + 1; // 1 to 4611686018427387903 - int dr1 = rng.nextInt() & 0xffff; // 0 to 65535 - byte[] perKeyBytes = new byte[48]; // size=48, non-null - rng.nextBytes(perKeyBytes); - return new Recipient(u1, d1, dr1, perKeyBytes); - } - - private static void roundTripVarint(long expected, byte [] bytes) throws Exception { - ByteBuffer bb = ByteBuffer.wrap(bytes); - writePayloadDeviceId(bb, expected); - InputStream stream = new ByteArrayInputStream(bytes, 0, bb.position()); - long got = MultiRecipientMessageProvider.readVarint(stream); - assertEquals(expected, got, String.format("encoded as: %s", Arrays.toString(bytes))); - } - - @Test - void testVarintPayload() throws Exception { - Random rng = new Random(); - byte[] bytes = new byte[12]; - - // some static test cases - for (long i = 1L; i <= 10L; i++) { - roundTripVarint(i, bytes); - } - roundTripVarint(Long.MAX_VALUE, bytes); - - for (int i = 0; i < 1000; i++) { - // we need to ensure positive device IDs - long start = rng.nextLong() & Long.MAX_VALUE; - if (start == 0L) start = 1L; - - // run the test for this case - roundTripVarint(start, bytes); - } - } - - @Test - void testMultiPayloadRoundtrip() throws Exception { - Random rng = new java.util.Random(); - List expected = new LinkedList<>(); - for(int i = 0; i < 100; i++) { - expected.add(genRecipient(rng)); - } - - byte[] buffer = new byte[100 + expected.size() * 100]; - InputStream entityStream = initializeMultiPayload(expected, buffer); - MultiRecipientMessageProvider provider = new MultiRecipientMessageProvider(); - // the provider ignores the headers, java reflection, etc. so we don't use those here. - MultiRecipientMessage res = provider.readFrom(null, null, null, null, null, entityStream); - List got = Arrays.asList(res.getRecipients()); - - assertEquals(expected, got); - } - - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java deleted file mode 100644 index b5ebe1d15..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java +++ /dev/null @@ -1,1462 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.mockito.ArgumentMatchers.refEq; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collections; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.Executors; -import java.util.stream.Stream; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedHashMap; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import org.apache.commons.lang3.RandomStringUtils; -import org.assertj.core.api.Condition; -import org.glassfish.jersey.server.ServerProperties; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.ServerPublicParams; -import org.signal.libsignal.zkgroup.ServerSecretParams; -import org.signal.libsignal.zkgroup.VerificationFailedException; -import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; -import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse; -import org.signal.libsignal.zkgroup.profiles.PniCredentialResponse; -import org.signal.libsignal.zkgroup.profiles.ProfileKey; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext; -import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialResponse; -import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; -import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration; -import org.whispersystems.textsecuregcm.entities.Badge; -import org.whispersystems.textsecuregcm.entities.BadgeSvg; -import org.whispersystems.textsecuregcm.entities.BaseProfileResponse; -import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckRequest; -import org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse; -import org.whispersystems.textsecuregcm.entities.CreateProfileRequest; -import org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse; -import org.whispersystems.textsecuregcm.entities.PniCredentialProfileResponse; -import org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes; -import org.whispersystems.textsecuregcm.entities.ProfileKeyCredentialProfileResponse; -import org.whispersystems.textsecuregcm.entities.VersionedProfileResponse; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.s3.PolicySigner; -import org.whispersystems.textsecuregcm.s3.PostPolicyGenerator; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountBadge; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.storage.ProfilesManager; -import org.whispersystems.textsecuregcm.storage.VersionedProfile; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.util.TestClock; -import org.whispersystems.textsecuregcm.util.Util; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; - -@ExtendWith(DropwizardExtensionsSupport.class) -class ProfileControllerTest { - - private static final Clock clock = TestClock.pinned(Instant.ofEpochSecond(42)); - private static final AccountsManager accountsManager = mock(AccountsManager.class); - private static final ProfilesManager profilesManager = mock(ProfilesManager.class); - private static final RateLimiters rateLimiters = mock(RateLimiters.class); - private static final RateLimiter rateLimiter = mock(RateLimiter.class); - private static final RateLimiter usernameRateLimiter = mock(RateLimiter.class); - - private static final S3Client s3client = mock(S3Client.class); - private static final PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator("us-west-1", "profile-bucket", - "accessKey"); - private static final PolicySigner policySigner = new PolicySigner("accessSecret", "us-west-1"); - private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class); - - private static final byte[] UNIDENTIFIED_ACCESS_KEY = "test-uak".getBytes(StandardCharsets.UTF_8); - private static final String ACCOUNT_IDENTITY_KEY = "barz"; - private static final String ACCOUNT_PHONE_NUMBER_IDENTITY_KEY = "bazz"; - private static final String ACCOUNT_TWO_IDENTITY_KEY = "bar"; - private static final String ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY = "baz"; - private static final String BASE_64_URL_USERNAME_HASH = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE"; - private static final byte[] USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH); - @SuppressWarnings("unchecked") - private static final DynamicConfigurationManager dynamicConfigurationManager = mock( - DynamicConfigurationManager.class); - - private DynamicPaymentsConfiguration dynamicPaymentsConfiguration; - private Account profileAccount; - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .addProvider(new RateLimitExceededExceptionMapper()) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new ProfileController( - clock, - rateLimiters, - accountsManager, - profilesManager, - dynamicConfigurationManager, - (acceptableLanguages, accountBadges, isSelf) -> List.of(new Badge("TEST", "other", "Test Badge", - "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"))) - ), - new BadgesConfiguration(List.of( - 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"))), - 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"))), - 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"))), - 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"))) - ), List.of("TEST1"), Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")), - s3client, - postPolicyGenerator, - policySigner, - "profilesBucket", - zkProfileOperations, - Executors.newSingleThreadExecutor())) - .build(); - - @BeforeEach - void setup() { - reset(s3client); - - AccountsHelper.setupMockUpdate(accountsManager); - - dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class); - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration); - when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(Collections.emptyList()); - - when(rateLimiters.getProfileLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameRateLimiter); - - profileAccount = mock(Account.class); - - when(profileAccount.getIdentityKey()).thenReturn(ACCOUNT_TWO_IDENTITY_KEY); - when(profileAccount.getPhoneNumberIdentityKey()).thenReturn(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY); - when(profileAccount.getUuid()).thenReturn(AuthHelper.VALID_UUID_TWO); - when(profileAccount.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI_TWO); - when(profileAccount.isEnabled()).thenReturn(true); - when(profileAccount.isSenderKeySupported()).thenReturn(false); - when(profileAccount.isAnnouncementGroupSupported()).thenReturn(false); - when(profileAccount.isChangeNumberSupported()).thenReturn(false); - when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty()); - when(profileAccount.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH)); - when(profileAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of("1337".getBytes())); - - Account capabilitiesAccount = mock(Account.class); - - when(capabilitiesAccount.getIdentityKey()).thenReturn(ACCOUNT_IDENTITY_KEY); - when(capabilitiesAccount.getPhoneNumberIdentityKey()).thenReturn(ACCOUNT_PHONE_NUMBER_IDENTITY_KEY); - when(capabilitiesAccount.isEnabled()).thenReturn(true); - when(capabilitiesAccount.isSenderKeySupported()).thenReturn(true); - when(capabilitiesAccount.isAnnouncementGroupSupported()).thenReturn(true); - when(capabilitiesAccount.isChangeNumberSupported()).thenReturn(true); - - when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(profileAccount)); - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(profileAccount)); - when(accountsManager.getByPhoneNumberIdentifier(AuthHelper.VALID_PNI_TWO)).thenReturn(Optional.of(profileAccount)); - when(accountsManager.getByUsernameHash(USERNAME_HASH)).thenReturn(Optional.of(profileAccount)); - - when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(capabilitiesAccount)); - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(capabilitiesAccount)); - - when(profilesManager.get(eq(AuthHelper.VALID_UUID), eq("someversion"))).thenReturn(Optional.empty()); - when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion"))).thenReturn(Optional.of(new VersionedProfile( - "validversion", "validname", "profiles/validavatar", "emoji", "about", null, "validcommitmnet".getBytes()))); - - when(accountsManager.getByAccountIdentifier(AuthHelper.INVALID_UUID)).thenReturn(Optional.empty()); - - clearInvocations(rateLimiter); - clearInvocations(accountsManager); - clearInvocations(usernameRateLimiter); - clearInvocations(profilesManager); - clearInvocations(zkProfileOperations); - } - - @AfterEach - void teardown() { - reset(accountsManager); - reset(rateLimiter); - } - - @Test - void testProfileGetByAci() throws RateLimitExceededException { - BaseProfileResponse profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(BaseProfileResponse.class); - - assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY); - assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>( - badge -> "Test Badge".equals(badge.getName()), "has badge with expected name")); - - verify(accountsManager).getByAccountIdentifier(AuthHelper.VALID_UUID_TWO); - verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID); - } - - @Test - void testProfileGetByAciRateLimited() throws RateLimitExceededException { - doThrow(new RateLimitExceededException(Duration.ofSeconds(13), true)).when(rateLimiter) - .validate(AuthHelper.VALID_UUID); - - Response response= resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(413); - assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds())); - } - - @Test - void testProfileGetByAciUnidentified() throws RateLimitExceededException { - BaseProfileResponse profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) - .get(BaseProfileResponse.class); - - assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY); - assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>( - badge -> "Test Badge".equals(badge.getName()), "has badge with expected name")); - - verify(accountsManager).getByAccountIdentifier(AuthHelper.VALID_UUID_TWO); - verify(rateLimiter, never()).validate(AuthHelper.VALID_UUID); - } - - @Test - void testProfileGetByAciUnidentifiedBadKey() { - final Response response = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("incorrect".getBytes())) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testProfileGetByAciUnidentifiedAccountNotFound() { - final Response response = resources.getJerseyTest() - .target("/v1/profile/" + UUID.randomUUID()) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testProfileGetByPni() throws RateLimitExceededException { - BaseProfileResponse profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_PNI_TWO) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(BaseProfileResponse.class); - - assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY); - assertThat(profile.getBadges()).isEmpty(); - assertThat(profile.getUuid()).isEqualTo(AuthHelper.VALID_PNI_TWO); - assertThat(profile.getCapabilities()).isNotNull(); - assertThat(profile.isUnrestrictedUnidentifiedAccess()).isFalse(); - assertThat(profile.getUnidentifiedAccess()).isNull(); - - verify(accountsManager).getByPhoneNumberIdentifier(AuthHelper.VALID_PNI_TWO); - verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID); - } - - @Test - void testProfileGetByPniRateLimited() throws RateLimitExceededException { - doThrow(new RateLimitExceededException(Duration.ofSeconds(13), true)).when(rateLimiter) - .validate(AuthHelper.VALID_UUID); - - Response response= resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_PNI_TWO) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(413); - assertThat(response.getHeaderString("Retry-After")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds())); - } - - @Test - void testProfileGetByPniUnidentified() throws RateLimitExceededException { - final Response response = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_PNI_TWO) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - - verify(accountsManager).getByPhoneNumberIdentifier(AuthHelper.VALID_PNI_TWO); - verify(rateLimiter, never()).validate(AuthHelper.VALID_UUID); - } - - @Test - void testProfileGetByPniUnidentifiedBadKey() { - final Response response = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_PNI_TWO) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("incorrect".getBytes())) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testProfileGetUnauthorized() { - Response response = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) - .request() - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - - @Test - void testProfileGetDisabled() { - Response response = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testProfileCapabilities() { - BaseProfileResponse profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(BaseProfileResponse.class); - - assertThat(profile.getCapabilities().gv1Migration()).isTrue(); - assertThat(profile.getCapabilities().senderKey()).isTrue(); - assertThat(profile.getCapabilities().announcementGroup()).isTrue(); - - profile = resources - .getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .get(BaseProfileResponse.class); - - assertThat(profile.getCapabilities().gv1Migration()).isTrue(); - assertThat(profile.getCapabilities().senderKey()).isFalse(); - assertThat(profile.getCapabilities().announcementGroup()).isFalse(); - } - - @Test - void testSetProfileWantAvatarUpload() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - ProfileAvatarUploadAttributes uploadAttributes = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new CreateProfileRequest(commitment, "someversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", null, null, - null, true, false, List.of()), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID), eq("someversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID), profileArgumentCaptor.capture()); - - verifyNoMoreInteractions(s3client); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).isEqualTo(uploadAttributes.getKey()); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("someversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileWantAvatarUploadWithBadProfileSize() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new CreateProfileRequest(commitment, "someversion", "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890", null, null, null, true, false, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(422); - } - - @Test - void testSetProfileWithoutAvatarUpload() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", null, null, - null, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("anotherversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - - verifyNoMoreInteractions(s3client); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).isNull(); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("anotherversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileWithAvatarUploadAndPreviousAvatar() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID_TWO); - - resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", - "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", - null, null, - null, true, false, List.of()), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - verify(s3client, times(1)).deleteObject(eq(DeleteObjectRequest.builder().bucket("profilesBucket").key("profiles/validavatar").build())); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).startsWith("profiles/"); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("validversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileClearPreviousAvatar() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID_TWO); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", null, null, null, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - verify(s3client, times(1)).deleteObject(eq(DeleteObjectRequest.builder().bucket("profilesBucket").key("profiles/validavatar").build())); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).isNull(); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("validversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileWithSameAvatar() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID_TWO); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", null, null, null, true, true, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - verify(s3client, never()).deleteObject(any(DeleteObjectRequest.class)); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).isEqualTo("profiles/validavatar"); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("validversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileClearPreviousAvatarDespiteSameAvatarFlagSet() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID_TWO); - - resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", - "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", - null, null, - null, false, true, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - verify(s3client, times(1)).deleteObject(eq(DeleteObjectRequest.builder().bucket("profilesBucket").key("profiles/validavatar").build())); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).isNull(); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("validversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileWithSameAvatarDespiteNoPreviousAvatar() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678", null, null, null, true, true, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID), eq("validversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID), profileArgumentCaptor.capture()); - verify(s3client, never()).deleteObject(any(DeleteObjectRequest.class)); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).isNull(); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("validversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo("123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileExtendedName() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID_TWO); - - final String name = RandomStringUtils.randomAlphabetic(380); - - resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "validversion", name, null, null, null, true, false, List.of()), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - verify(s3client, times(1)).deleteObject(eq(DeleteObjectRequest.builder().bucket("profilesBucket").key("profiles/validavatar").build())); - - assertThat(profileArgumentCaptor.getValue().getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profileArgumentCaptor.getValue().getAvatar()).startsWith("profiles/"); - assertThat(profileArgumentCaptor.getValue().getVersion()).isEqualTo("validversion"); - assertThat(profileArgumentCaptor.getValue().getName()).isEqualTo(name); - assertThat(profileArgumentCaptor.getValue().getAboutEmoji()).isNull(); - assertThat(profileArgumentCaptor.getValue().getAbout()).isNull(); - } - - @Test - void testSetProfileEmojiAndBioText() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - final String name = RandomStringUtils.randomAlphabetic(380); - final String emoji = RandomStringUtils.randomAlphanumeric(80); - final String text = RandomStringUtils.randomAlphanumeric(720); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", name, emoji, text, null, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("anotherversion")); - verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - - verifyNoMoreInteractions(s3client); - - final VersionedProfile profile = profileArgumentCaptor.getValue(); - assertThat(profile.getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profile.getAvatar()).isNull(); - assertThat(profile.getVersion()).isEqualTo("anotherversion"); - assertThat(profile.getName()).isEqualTo(name); - assertThat(profile.getAboutEmoji()).isEqualTo(emoji); - assertThat(profile.getAbout()).isEqualTo(text); - assertThat(profile.getPaymentAddress()).isNull(); - } - - @Test - void testSetProfilePaymentAddress() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - final String name = RandomStringUtils.randomAlphabetic(380); - final String paymentAddress = RandomStringUtils.randomAlphanumeric(776); - - Response response = resources.getJerseyTest() - .target("/v1/profile") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "yetanotherversion", name, null, null, paymentAddress, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager).get(eq(AuthHelper.VALID_UUID_TWO), eq("yetanotherversion")); - verify(profilesManager).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - - verifyNoMoreInteractions(s3client); - - final VersionedProfile profile = profileArgumentCaptor.getValue(); - assertThat(profile.getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profile.getAvatar()).isNull(); - assertThat(profile.getVersion()).isEqualTo("yetanotherversion"); - assertThat(profile.getName()).isEqualTo(name); - assertThat(profile.getAboutEmoji()).isNull(); - assertThat(profile.getAbout()).isNull(); - assertThat(profile.getPaymentAddress()).isEqualTo(paymentAddress); - } - - @Test - void testSetProfilePaymentAddressCountryNotAllowed() throws InvalidInputException { - when(dynamicPaymentsConfiguration.getDisallowedPrefixes()) - .thenReturn(List.of(AuthHelper.VALID_NUMBER_TWO.substring(0, 3))); - - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - final String name = RandomStringUtils.randomAlphabetic(380); - final String paymentAddress = RandomStringUtils.randomAlphanumeric(776); - - Response response = resources.getJerseyTest() - .target("/v1/profile") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity( - new CreateProfileRequest(commitment, "yetanotherversion", name, null, null, paymentAddress, false, false, - List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(403); - assertThat(response.hasEntity()).isFalse(); - - verify(profilesManager, never()).set(any(), any()); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testSetProfilePaymentAddressCountryNotAllowedExistingPaymentAddress( - final boolean existingPaymentAddressOnProfile) throws InvalidInputException { - when(dynamicPaymentsConfiguration.getDisallowedPrefixes()) - .thenReturn(List.of(AuthHelper.VALID_NUMBER_TWO.substring(0, 3))); - - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), any())) - .thenReturn(Optional.of( - new VersionedProfile("1", "name", null, null, null, - existingPaymentAddressOnProfile ? RandomStringUtils.randomAlphanumeric(776) : null, - commitment.serialize()))); - - final String name = RandomStringUtils.randomAlphabetic(380); - final String paymentAddress = RandomStringUtils.randomAlphanumeric(776); - - Response response = resources.getJerseyTest() - .target("/v1/profile") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity( - new CreateProfileRequest(commitment, "yetanotherversion", name, null, null, paymentAddress, false, false, - List.of()), MediaType.APPLICATION_JSON_TYPE)); - - if (existingPaymentAddressOnProfile) { - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - ArgumentCaptor profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class); - - verify(profilesManager).get(eq(AuthHelper.VALID_UUID_TWO), eq("yetanotherversion")); - verify(profilesManager).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture()); - - verifyNoMoreInteractions(s3client); - - final VersionedProfile profile = profileArgumentCaptor.getValue(); - assertThat(profile.getCommitment()).isEqualTo(commitment.serialize()); - assertThat(profile.getAvatar()).isNull(); - assertThat(profile.getVersion()).isEqualTo("yetanotherversion"); - assertThat(profile.getName()).isEqualTo(name); - assertThat(profile.getAboutEmoji()).isNull(); - assertThat(profile.getAbout()).isNull(); - assertThat(profile.getPaymentAddress()).isEqualTo(paymentAddress); - } else { - assertThat(response.getStatus()).isEqualTo(403); - assertThat(response.hasEntity()).isFalse(); - - verify(profilesManager, never()).set(any(), any()); - } - } - - @Test - void testGetProfileByVersion() throws RateLimitExceededException { - VersionedProfileResponse profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VersionedProfileResponse.class); - - assertThat(profile.getBaseProfileResponse().getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY); - assertThat(profile.getName()).isEqualTo("validname"); - assertThat(profile.getAbout()).isEqualTo("about"); - assertThat(profile.getAboutEmoji()).isEqualTo("emoji"); - assertThat(profile.getAvatar()).isEqualTo("profiles/validavatar"); - assertThat(profile.getBaseProfileResponse().getCapabilities().gv1Migration()).isTrue(); - assertThat(profile.getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID_TWO); - assertThat(profile.getBaseProfileResponse().getBadges()).hasSize(1).element(0).has(new Condition<>( - badge -> "Test Badge".equals(badge.getName()), "has badge with expected name")); - - verify(accountsManager, times(1)).getByAccountIdentifier(eq(AuthHelper.VALID_UUID_TWO)); - verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq("validversion")); - - verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID); - } - - @Test - void testSetProfileUpdatesAccountCurrentVersion() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID_TWO); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - final String name = RandomStringUtils.randomAlphabetic(380); - final String paymentAddress = RandomStringUtils.randomAlphanumeric(776); - - Response response = resources.getJerseyTest() - .target("/v1/profile") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "someversion", name, null, null, paymentAddress, false, false, List.of()), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - verify(AuthHelper.VALID_ACCOUNT_TWO).setCurrentProfileVersion("someversion"); - } - - @Test - void testGetProfileReturnsNoPaymentAddressIfCurrentVersionMismatch() { - when(profilesManager.get(AuthHelper.VALID_UUID_TWO, "validversion")).thenReturn( - Optional.of(new VersionedProfile(null, null, null, null, null, "paymentaddress", null))); - VersionedProfileResponse profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VersionedProfileResponse.class); - assertThat(profile.getPaymentAddress()).isEqualTo("paymentaddress"); - - when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of("validversion")); - profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VersionedProfileResponse.class); - assertThat(profile.getPaymentAddress()).isEqualTo("paymentaddress"); - - when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of("someotherversion")); - profile = resources.getJerseyTest() - .target("/v1/profile/" + AuthHelper.VALID_UUID_TWO + "/validversion") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VersionedProfileResponse.class); - assertThat(profile.getPaymentAddress()).isNull(); - } - - @Test - void testSetProfileBadges() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - final String name = RandomStringUtils.randomAlphabetic(380); - final String emoji = RandomStringUtils.randomAlphanumeric(80); - final String text = RandomStringUtils.randomAlphanumeric(720); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", name, emoji, text, null, false, false, List.of("TEST2")), MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - @SuppressWarnings("unchecked") - ArgumentCaptor> badgeCaptor = ArgumentCaptor.forClass(List.class); - verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture()); - - List badges = badgeCaptor.getValue(); - assertThat(badges).isNotNull().hasSize(1).containsOnly(new AccountBadge("TEST2", Instant.ofEpochSecond(42 + 86400), true)); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of( - new AccountBadge("TEST2", Instant.ofEpochSecond(42 + 86400), true) - )); - - response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", name, emoji, text, null, false, false, List.of("TEST3", "TEST2")), MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - //noinspection unchecked - badgeCaptor = ArgumentCaptor.forClass(List.class); - verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture()); - - badges = badgeCaptor.getValue(); - assertThat(badges).isNotNull().hasSize(2).containsOnly( - new AccountBadge("TEST3", Instant.ofEpochSecond(42 + 86400), true), - new AccountBadge("TEST2", Instant.ofEpochSecond(42 + 86400), true)); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of( - new AccountBadge("TEST3", Instant.ofEpochSecond(42 + 86400), true), - new AccountBadge("TEST2", Instant.ofEpochSecond(42 + 86400), true) - )); - - response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", name, emoji, text, null, false, false, List.of("TEST2", "TEST3")), MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - //noinspection unchecked - badgeCaptor = ArgumentCaptor.forClass(List.class); - verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture()); - - badges = badgeCaptor.getValue(); - assertThat(badges).isNotNull().hasSize(2).containsOnly( - new AccountBadge("TEST2", Instant.ofEpochSecond(42 + 86400), true), - new AccountBadge("TEST3", Instant.ofEpochSecond(42 + 86400), true)); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of( - new AccountBadge("TEST2", Instant.ofEpochSecond(42 + 86400), true), - new AccountBadge("TEST3", Instant.ofEpochSecond(42 + 86400), true) - )); - - response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new CreateProfileRequest(commitment, "anotherversion", name, emoji, text, null, false, false, List.of("TEST1")), MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - //noinspection unchecked - badgeCaptor = ArgumentCaptor.forClass(List.class); - verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture()); - - badges = badgeCaptor.getValue(); - assertThat(badges).isNotNull().hasSize(3).containsOnly( - new AccountBadge("TEST1", Instant.ofEpochSecond(42 + 86400), true), - new AccountBadge("TEST2", Instant.ofEpochSecond(42 + 86400), false), - new AccountBadge("TEST3", Instant.ofEpochSecond(42 + 86400), false)); - } - - @ParameterizedTest - @MethodSource - void testGetProfileWithProfileKeyCredential(final MultivaluedMap authHeaders) - throws VerificationFailedException, InvalidInputException { - final String version = "version"; - final byte[] unidentifiedAccessKey = "test-uak".getBytes(StandardCharsets.UTF_8); - - final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); - final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); - - final ServerZkProfileOperations serverZkProfile = new ServerZkProfileOperations(serverSecretParams); - final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams); - - final byte[] profileKeyBytes = new byte[32]; - new SecureRandom().nextBytes(profileKeyBytes); - - final ProfileKey profileKey = new ProfileKey(profileKeyBytes); - final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(AuthHelper.VALID_UUID); - - final VersionedProfile versionedProfile = mock(VersionedProfile.class); - when(versionedProfile.getCommitment()).thenReturn(profileKeyCommitment.serialize()); - - final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext = - clientZkProfile.createProfileKeyCredentialRequestContext(AuthHelper.VALID_UUID, profileKey); - - final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); - when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version)); - when(account.isEnabled()).thenReturn(true); - when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); - - final ProfileKeyCredentialResponse credentialResponse = - serverZkProfile.issueProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment); - - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile)); - when(zkProfileOperations.issueProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment)) - .thenReturn(credentialResponse); - - final ProfileKeyCredentialProfileResponse profile = resources.getJerseyTest() - .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, - HexFormat.of().formatHex(credentialRequest.serialize()))) - .request() - .headers(authHeaders) - .get(ProfileKeyCredentialProfileResponse.class); - - assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); - assertThat(profile.getCredential()).isEqualTo(credentialResponse); - - verify(zkProfileOperations).issueProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment); - verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); - - final ClientZkProfileOperations clientZkProfileCipher = new ClientZkProfileOperations(serverPublicParams); - assertThatNoException().isThrownBy(() -> - clientZkProfileCipher.receiveProfileKeyCredential(profileKeyCredentialRequestContext, profile.getCredential())); - } - - private static Stream testGetProfileWithProfileKeyCredential() { - return Stream.of( - Arguments.of(new MultivaluedHashMap<>(Map.of(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_KEY)))), - Arguments.of(new MultivaluedHashMap<>(Map.of("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)))), - Arguments.of(new MultivaluedHashMap<>(Map.of("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)))) - ); - } - - @Test - void testGetProfileWithProfileKeyCredentialVersionNotFound() throws VerificationFailedException { - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); - when(account.getCurrentProfileVersion()).thenReturn(Optional.of("version")); - when(account.isEnabled()).thenReturn(true); - - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(profilesManager.get(any(), any())).thenReturn(Optional.empty()); - - final ProfileKeyCredentialProfileResponse profile = resources.getJerseyTest() - .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, "version-that-does-not-exist", "credential-request")) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(ProfileKeyCredentialProfileResponse.class); - - assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); - assertThat(profile.getCredential()).isNull(); - - verify(zkProfileOperations, never()).issueProfileKeyCredential(any(), any(), any()); - verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); - } - - @Test - void testGetProfileWithPniCredential() throws InvalidInputException, VerificationFailedException { - final String version = "version"; - - final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); - final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); - final ServerZkProfileOperations serverZkProfile = new ServerZkProfileOperations(serverSecretParams); - final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams); - - final byte[] profileKeyBytes = new byte[32]; - new SecureRandom().nextBytes(profileKeyBytes); - - final ProfileKey profileKey = new ProfileKey(profileKeyBytes); - final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(AuthHelper.VALID_UUID); - - final VersionedProfile versionedProfile = mock(VersionedProfile.class); - when(versionedProfile.getCommitment()).thenReturn(profileKeyCommitment.serialize()); - - final ProfileKeyCredentialRequest credentialRequest = - clientZkProfile.createPniCredentialRequestContext(AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, profileKey) - .getRequest(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); - when(account.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI); - when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version)); - when(account.isEnabled()).thenReturn(true); - - final PniCredentialResponse credentialResponse = - serverZkProfile.issuePniCredential(credentialRequest, AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, profileKeyCommitment); - - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile)); - when(zkProfileOperations.issuePniCredential(credentialRequest, AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, profileKeyCommitment)) - .thenReturn(credentialResponse); - - final PniCredentialProfileResponse profile = resources.getJerseyTest() - .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, - HexFormat.of().formatHex(credentialRequest.serialize()))) - .queryParam("credentialType", "pni") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(PniCredentialProfileResponse.class); - - assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); - assertThat(profile.getPniCredential()).isEqualTo(credentialResponse); - - verify(zkProfileOperations, never()).issueProfileKeyCredential(any(), any(), any()); - verify(zkProfileOperations).issuePniCredential(credentialRequest, AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, profileKeyCommitment); - } - - @Test - void testGetProfileWithPniCredentialNotSelf() throws InvalidInputException, VerificationFailedException { - final String version = "version"; - - final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); - final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); - final ServerZkProfileOperations serverZkProfile = new ServerZkProfileOperations(serverSecretParams); - final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams); - - final byte[] profileKeyBytes = new byte[32]; - new SecureRandom().nextBytes(profileKeyBytes); - - final ProfileKey profileKey = new ProfileKey(profileKeyBytes); - final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(AuthHelper.VALID_UUID); - - final VersionedProfile versionedProfile = mock(VersionedProfile.class); - when(versionedProfile.getCommitment()).thenReturn(profileKeyCommitment.serialize()); - - final ProfileKeyCredentialRequest credentialRequest = - clientZkProfile.createProfileKeyCredentialRequestContext(AuthHelper.VALID_UUID, profileKey).getRequest(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); - when(account.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI); - when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version)); - when(account.isEnabled()).thenReturn(true); - - final PniCredentialResponse credentialResponse = - serverZkProfile.issuePniCredential(credentialRequest, AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, profileKeyCommitment); - - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile)); - when(zkProfileOperations.issuePniCredential(credentialRequest, AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, profileKeyCommitment)) - .thenReturn(credentialResponse); - - final Response response = resources.getJerseyTest() - .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, - HexFormat.of().formatHex(credentialRequest.serialize()))) - .queryParam("credentialType", "pni") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .get(); - - assertThat(response.getStatus()).isEqualTo(403); - - verify(zkProfileOperations, never()).issueProfileKeyCredential(any(), any(), any()); - verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); - } - - @ParameterizedTest - @MethodSource - void testGetProfileWithExpiringProfileKeyCredential(final MultivaluedMap authHeaders) - throws VerificationFailedException, InvalidInputException { - final String version = "version"; - final byte[] unidentifiedAccessKey = "test-uak".getBytes(StandardCharsets.UTF_8); - - final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); - final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams(); - - final ServerZkProfileOperations serverZkProfile = new ServerZkProfileOperations(serverSecretParams); - final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams); - - final byte[] profileKeyBytes = new byte[32]; - new SecureRandom().nextBytes(profileKeyBytes); - - final ProfileKey profileKey = new ProfileKey(profileKeyBytes); - final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(AuthHelper.VALID_UUID); - - final VersionedProfile versionedProfile = mock(VersionedProfile.class); - when(versionedProfile.getCommitment()).thenReturn(profileKeyCommitment.serialize()); - - final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext = - clientZkProfile.createProfileKeyCredentialRequestContext(AuthHelper.VALID_UUID, profileKey); - - final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest(); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); - when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version)); - when(account.isEnabled()).thenReturn(true); - when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey)); - - final Instant expiration = Instant.now().plus(ProfileController.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION) - .truncatedTo(ChronoUnit.DAYS); - - final ExpiringProfileKeyCredentialResponse credentialResponse = - serverZkProfile.issueExpiringProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment, expiration); - - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile)); - when(zkProfileOperations.issueExpiringProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment, expiration)) - .thenReturn(credentialResponse); - - final ExpiringProfileKeyCredentialProfileResponse profile = resources.getJerseyTest() - .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, version, - HexFormat.of().formatHex(credentialRequest.serialize()))) - .queryParam("credentialType", "expiringProfileKey") - .request() - .headers(authHeaders) - .get(ExpiringProfileKeyCredentialProfileResponse.class); - - assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); - assertThat(profile.getCredential()).isEqualTo(credentialResponse); - - verify(zkProfileOperations).issueExpiringProfileKeyCredential(credentialRequest, AuthHelper.VALID_UUID, profileKeyCommitment, expiration); - verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); - - final ClientZkProfileOperations clientZkProfileCipher = new ClientZkProfileOperations(serverPublicParams); - assertThatNoException().isThrownBy(() -> - clientZkProfileCipher.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, profile.getCredential())); - } - - private static Stream testGetProfileWithExpiringProfileKeyCredential() { - return Stream.of( - Arguments.of(new MultivaluedHashMap<>(Map.of(OptionalAccess.UNIDENTIFIED, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_KEY)))), - Arguments.of(new MultivaluedHashMap<>(Map.of("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)))), - Arguments.of(new MultivaluedHashMap<>(Map.of("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)))) - ); - } - - @Test - void testGetProfileWithPniCredentialVersionNotFound() throws VerificationFailedException { - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); - when(account.getCurrentProfileVersion()).thenReturn(Optional.of("version")); - when(account.isEnabled()).thenReturn(true); - - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account)); - when(profilesManager.get(any(), any())).thenReturn(Optional.empty()); - - final PniCredentialProfileResponse profile = resources.getJerseyTest() - .target(String.format("/v1/profile/%s/%s/%s", AuthHelper.VALID_UUID, "version-that-does-not-exist", "credential-request")) - .queryParam("credentialType", "pni") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(PniCredentialProfileResponse.class); - - assertThat(profile.getVersionedProfileResponse().getBaseProfileResponse().getUuid()).isEqualTo(AuthHelper.VALID_UUID); - assertThat(profile.getPniCredential()).isNull(); - - verify(zkProfileOperations, never()).issueProfileKeyCredential(any(), any(), any()); - verify(zkProfileOperations, never()).issuePniCredential(any(), any(), any(), any()); - } - - @Test - void testSetProfileBadgesMissingFromRequest() throws InvalidInputException { - ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(AuthHelper.VALID_UUID); - - clearInvocations(AuthHelper.VALID_ACCOUNT_TWO); - - final String name = RandomStringUtils.randomAlphabetic(380); - final String emoji = RandomStringUtils.randomAlphanumeric(80); - final String text = RandomStringUtils.randomAlphanumeric(720); - - when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of( - new AccountBadge("TEST", Instant.ofEpochSecond(42 + 86400), true) - )); - - // Older clients may not include badges in their requests - final String requestJson = String.format(""" - { - "commitment": "%s", - "version": "version", - "name": "%s", - "avatar": false, - "aboutEmoji": "%s", - "about": "%s" - } - """, - Base64.getEncoder().encodeToString(commitment.serialize()), name, emoji, text); - - Response response = resources.getJerseyTest() - .target("/v1/profile/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.json(requestJson)); - - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.hasEntity()).isFalse(); - - verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), eq(List.of(new AccountBadge("TEST", Instant.ofEpochSecond(42 + 86400), true)))); - } - - @Test - void testBatchIdentityCheck() { - try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request() - .post(Entity.json(new BatchIdentityCheckRequest(List.of( - new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null, - convertStringToFingerprint(ACCOUNT_IDENTITY_KEY)), - new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO, - convertStringToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY)), - new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_UUID_TWO, - convertStringToFingerprint(ACCOUNT_TWO_IDENTITY_KEY)), - new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null, - convertStringToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY)) - ))))) { - assertThat(response).isNotNull(); - assertThat(response.getStatus()).isEqualTo(200); - BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class); - assertThat(identityCheckResponse).isNotNull(); - assertThat(identityCheckResponse.elements()).isNotNull().isEmpty(); - } - - Condition isAnExpectedUuid = new Condition<>(element -> { - if (AuthHelper.VALID_UUID.equals(element.aci())) { - return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_IDENTITY_KEY), element.identityKey()); - } else if (AuthHelper.VALID_PNI_TWO.equals(element.uuid())) { - return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY), element.identityKey()); - } else if (AuthHelper.VALID_UUID_TWO.equals(element.uuid())) { - return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_IDENTITY_KEY), element.identityKey()); - } else { - return false; - } - }, "is an expected UUID with the correct identity key"); - - try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request() - .post(Entity.json(new BatchIdentityCheckRequest(List.of( - new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null, convertStringToFingerprint("else1234")), - new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO, - convertStringToFingerprint("another1")), - new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_UUID_TWO, - convertStringToFingerprint("another2")), - new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null, convertStringToFingerprint("456")) - ))))) { - assertThat(response).isNotNull(); - assertThat(response.getStatus()).isEqualTo(200); - BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class); - assertThat(identityCheckResponse).isNotNull(); - assertThat(identityCheckResponse.elements()).isNotNull().hasSize(3); - assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid); - assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid); - assertThat(identityCheckResponse.elements()).element(2).isNotNull().is(isAnExpectedUuid); - } - - List largeElementList = new ArrayList<>(List.of( - new BatchIdentityCheckRequest.Element(AuthHelper.VALID_UUID, null, convertStringToFingerprint("else1234")), - new BatchIdentityCheckRequest.Element(null, AuthHelper.VALID_PNI_TWO, convertStringToFingerprint("another1")), - new BatchIdentityCheckRequest.Element(AuthHelper.INVALID_UUID, null, convertStringToFingerprint("456")))); - for (int i = 0; i < 900; i++) { - largeElementList.add( - new BatchIdentityCheckRequest.Element(UUID.randomUUID(), null, convertStringToFingerprint("abcd"))); - } - try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request() - .post(Entity.json(new BatchIdentityCheckRequest(largeElementList)))) { - assertThat(response).isNotNull(); - assertThat(response.getStatus()).isEqualTo(200); - BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class); - assertThat(identityCheckResponse).isNotNull(); - assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2); - assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid); - assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid); - } - } - - @Test - void testBatchIdentityCheckDeserialization() throws Exception { - - Condition isAnExpectedUuid = new Condition<>(element -> { - if (AuthHelper.VALID_UUID.equals(element.aci())) { - return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_IDENTITY_KEY), element.identityKey()); - } else if (AuthHelper.VALID_PNI_TWO.equals(element.uuid())) { - return Arrays.equals(Base64.getDecoder().decode(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY), element.identityKey()); - } else { - return false; - } - }, "is an expected UUID with the correct identity key"); - - // null properties are ok to omit - String json = String.format(""" - { - "elements": [ - { "aci": "%s", "fingerprint": "%s" }, - { "uuid": "%s", "fingerprint": "%s" }, - { "aci": "%s", "fingerprint": "%s" } - ] - } - """, AuthHelper.VALID_UUID, Base64.getEncoder().encodeToString(convertStringToFingerprint("else1234")), - AuthHelper.VALID_PNI_TWO, Base64.getEncoder().encodeToString(convertStringToFingerprint("another1")), - AuthHelper.INVALID_UUID, Base64.getEncoder().encodeToString(convertStringToFingerprint("456"))); - try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request() - .post(Entity.entity(json, "application/json"))) { - assertThat(response).isNotNull(); - assertThat(response.getStatus()).isEqualTo(200); - String responseJson = response.readEntity(String.class); - - // `null` properties should be omitted from the response - assertThat(responseJson).doesNotContain("null"); - - BatchIdentityCheckResponse identityCheckResponse = SystemMapper.getMapper() - .readValue(responseJson, BatchIdentityCheckResponse.class); - assertThat(identityCheckResponse).isNotNull(); - assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2); - assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid); - assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid); - } - } - - @ParameterizedTest - @MethodSource - void testBatchIdentityCheckDeserializationBadRequest(final String json) { - try (Response response = resources.getJerseyTest().target("/v1/profile/identity_check/batch").request() - .post(Entity.entity(json, "application/json"))) { - assertThat(response).isNotNull(); - assertThat(response.getStatus()).isEqualTo(400); - } - } - - static Stream testBatchIdentityCheckDeserializationBadRequest() { - return Stream.of( - Arguments.of( // aci and uuid cannot both be null - """ - { - "elements": [ - { "aci": null, "uuid": null, "fingerprint": "%s" } - ] - } - """), - Arguments.of( // an empty string is also invalid - """ - { - "elements": [ - { "aci": "", "uuid": null, "fingerprint": "%s" } - ] - } - """ - ), - Arguments.of( // as is a blank string - """ - { - "elements": [ - { "aci": null, "uuid": " ", "fingerprint": "%s" } - ] - } - """), - Arguments.of( // aci and uuid cannot both be non-null - String.format(""" - { - "elements": [ - { "aci": "%s", "uuid": "%s", "fingerprint": "%s" } - ] - } - """, AuthHelper.VALID_UUID, AuthHelper.VALID_PNI, - Base64.getEncoder().encodeToString(convertStringToFingerprint("else1234")))) - ); - } - - private static byte[] convertStringToFingerprint(String base64) { - MessageDigest sha256; - try { - sha256 = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - return Util.truncate(sha256.digest(Base64.getDecoder().decode(base64)), 4); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProvisioningControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProvisioningControllerTest.java deleted file mode 100644 index 4384c1d16..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProvisioningControllerTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.whispersystems.textsecuregcm.controllers; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Base64; -import java.util.UUID; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.ProvisioningMessage; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.push.ProvisioningManager; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.websocket.ProvisioningAddress; - -@ExtendWith(DropwizardExtensionsSupport.class) -class ProvisioningControllerTest { - - private RateLimiter messagesRateLimiter; - - private static final RateLimiters rateLimiters = mock(RateLimiters.class); - private static final ProvisioningManager provisioningManager = mock(ProvisioningManager.class); - - private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .addProvider(new RateLimitExceededExceptionMapper()) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new ProvisioningController(rateLimiters, provisioningManager)) - .build(); - - @BeforeEach - void setUp() { - reset(rateLimiters, provisioningManager); - - messagesRateLimiter = mock(RateLimiter.class); - when(rateLimiters.getMessagesLimiter()).thenReturn(messagesRateLimiter); - } - - @Test - void sendProvisioningMessage() { - final String destination = UUID.randomUUID().toString(); - final byte[] messageBody = "test".getBytes(StandardCharsets.UTF_8); - - when(provisioningManager.sendProvisioningMessage(any(), any())).thenReturn(true); - - try (final Response response = RESOURCE_EXTENSION.getJerseyTest() - .target("/v1/provisioning/" + destination) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ProvisioningMessage(Base64.getMimeEncoder().encodeToString(messageBody)), - MediaType.APPLICATION_JSON))) { - - assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus()); - - final ArgumentCaptor provisioningAddressCaptor = - ArgumentCaptor.forClass(ProvisioningAddress.class); - - final ArgumentCaptor provisioningMessageCaptor = ArgumentCaptor.forClass(byte[].class); - - verify(provisioningManager).sendProvisioningMessage(provisioningAddressCaptor.capture(), - provisioningMessageCaptor.capture()); - - assertEquals(destination, provisioningAddressCaptor.getValue().getAddress()); - assertEquals(0, provisioningAddressCaptor.getValue().getDeviceId()); - - assertArrayEquals(messageBody, provisioningMessageCaptor.getValue()); - } - } - - @Test - void sendProvisioningMessageRateLimited() throws RateLimitExceededException { - final String destination = UUID.randomUUID().toString(); - final byte[] messageBody = "test".getBytes(StandardCharsets.UTF_8); - - doThrow(new RateLimitExceededException(Duration.ZERO, true)) - .when(messagesRateLimiter).validate(AuthHelper.VALID_UUID); - - try (final Response response = RESOURCE_EXTENSION.getJerseyTest() - .target("/v1/provisioning/" + destination) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(new ProvisioningMessage(Base64.getMimeEncoder().encodeToString(messageBody)), - MediaType.APPLICATION_JSON))) { - - assertEquals(413, response.getStatus()); - - verify(provisioningManager, never()).sendProvisioningMessage(any(), any()); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java deleted file mode 100644 index 720b8bac7..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java +++ /dev/null @@ -1,380 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.client.Entity; -import javax.ws.rs.client.Invocation; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.Response; -import org.apache.http.HttpStatus; -import org.glassfish.jersey.server.ServerProperties; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; -import org.whispersystems.textsecuregcm.auth.RegistrationLockError; -import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.RegistrationRequest; -import org.whispersystems.textsecuregcm.entities.RegistrationSession; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.registration.RegistrationServiceClient; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class RegistrationControllerTest { - - private static final String NUMBER = PhoneNumberUtil.getInstance().format( - PhoneNumberUtil.getInstance().getExampleNumber("US"), - PhoneNumberUtil.PhoneNumberFormat.E164); - - public static final String PASSWORD = "password"; - - private final AccountsManager accountsManager = mock(AccountsManager.class); - private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class); - private final RegistrationLockVerificationManager registrationLockVerificationManager = mock( - RegistrationLockVerificationManager.class); - private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock( - RegistrationRecoveryPasswordsManager.class); - private final RateLimiters rateLimiters = mock(RateLimiters.class); - - private final RateLimiter registrationLimiter = mock(RateLimiter.class); - - private final ResourceExtension resources = ResourceExtension.builder() - .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) - .addProvider(new RateLimitExceededExceptionMapper()) - .addProvider(new ImpossiblePhoneNumberExceptionMapper()) - .addProvider(new NonNormalizedPhoneNumberExceptionMapper()) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource( - new RegistrationController(accountsManager, - new PhoneVerificationTokenManager(registrationServiceClient, registrationRecoveryPasswordsManager), - registrationLockVerificationManager, rateLimiters)) - .build(); - - @BeforeEach - void setUp() { - when(rateLimiters.getRegistrationLimiter()).thenReturn(registrationLimiter); - } - - @Test - public void testRegistrationRequest() throws Exception { - assertFalse(new RegistrationRequest("", new byte[0], new AccountAttributes(), true).isValid()); - assertFalse(new RegistrationRequest("some", new byte[32], new AccountAttributes(), true).isValid()); - assertTrue(new RegistrationRequest("", new byte[32], new AccountAttributes(), true).isValid()); - assertTrue(new RegistrationRequest("some", new byte[0], new AccountAttributes(), true).isValid()); - } - - @Test - void unprocessableRequestJson() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request(); - try (Response response = request.post(Entity.json(unprocessableJson()))) { - assertEquals(400, response.getStatus()); - } - } - - @Test - void missingBasicAuthorization() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request(); - try (Response response = request.post(Entity.json(requestJson("sessionId")))) { - assertEquals(400, response.getStatus()); - } - } - - @Test - void invalidBasicAuthorization() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, "Basic but-invalid"); - try (Response response = request.post(Entity.json(invalidRequestJson()))) { - assertEquals(401, response.getStatus()); - } - } - - @Test - void invalidRequestBody() { - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(invalidRequestJson()))) { - assertEquals(422, response.getStatus()); - } - } - - @Test - void rateLimitedNumber() throws Exception { - doThrow(RateLimitExceededException.class) - .when(registrationLimiter).validate(NUMBER); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJson("sessionId")))) { - assertEquals(429, response.getStatus()); - } - } - - @Test - void registrationServiceTimeout() { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.failedFuture(new RuntimeException())); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJson("sessionId")))) { - assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus()); - } - } - - @Test - void recoveryPasswordManagerVerificationFailureOrTimeout() { - when(registrationRecoveryPasswordsManager.verify(any(), any())) - .thenReturn(CompletableFuture.failedFuture(new RuntimeException())); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(new byte[32])))) { - assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus()); - } - } - - @ParameterizedTest - @MethodSource - void registrationServiceSessionCheck(@Nullable final RegistrationSession session, final int expectedStatus, - final String message) { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(session))); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJson("sessionId")))) { - assertEquals(expectedStatus, response.getStatus(), message); - } - } - - static Stream registrationServiceSessionCheck() { - return Stream.of( - Arguments.of(null, 401, "session not found"), - Arguments.of(new RegistrationSession("+18005551234", false), 400, "session number mismatch"), - Arguments.of(new RegistrationSession(NUMBER, false), 401, "session not verified") - ); - } - - @Test - void recoveryPasswordManagerVerificationTrue() throws InterruptedException { - when(registrationRecoveryPasswordsManager.verify(any(), any())) - .thenReturn(CompletableFuture.completedFuture(true)); - when(accountsManager.create(any(), any(), any(), any(), any())) - .thenReturn(mock(Account.class)); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - final byte[] recoveryPassword = new byte[32]; - try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(recoveryPassword)))) { - assertEquals(200, response.getStatus()); - } - } - - @Test - void recoveryPasswordManagerVerificationFalse() throws InterruptedException { - when(registrationRecoveryPasswordsManager.verify(any(), any())) - .thenReturn(CompletableFuture.completedFuture(false)); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(new byte[32])))) { - assertEquals(403, response.getStatus()); - } - } - - @ParameterizedTest - @EnumSource(RegistrationLockError.class) - void registrationLock(final RegistrationLockError error) throws Exception { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true)))); - - when(accountsManager.getByE164(any())).thenReturn(Optional.of(mock(Account.class))); - - final Exception e = switch (error) { - case MISMATCH -> new WebApplicationException(error.getExpectedStatus()); - case RATE_LIMITED -> new RateLimitExceededException(null, true); - }; - doThrow(e) - .when(registrationLockVerificationManager).verifyRegistrationLock(any(), any()); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJson("sessionId")))) { - assertEquals(error.getExpectedStatus(), response.getStatus()); - } - } - - @ParameterizedTest - @CsvSource({ - "false, false, false, 200", - "true, false, false, 200", - "true, false, true, 200", - "true, true, false, 409", - "true, true, true, 200" - }) - void deviceTransferAvailable(final boolean existingAccount, final boolean transferSupported, - final boolean skipDeviceTransfer, final int expectedStatus) throws Exception { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true)))); - - final Optional maybeAccount; - if (existingAccount) { - final Account account = mock(Account.class); - when(account.isTransferSupported()).thenReturn(transferSupported); - maybeAccount = Optional.of(account); - } else { - maybeAccount = Optional.empty(); - } - when(accountsManager.getByE164(any())).thenReturn(maybeAccount); - when(accountsManager.create(any(), any(), any(), any(), any())).thenReturn(mock(Account.class)); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJson("sessionId", new byte[0], skipDeviceTransfer)))) { - assertEquals(expectedStatus, response.getStatus()); - } - } - - // this is functionally the same as deviceTransferAvailable(existingAccount=false) - @Test - void registrationSuccess() throws Exception { - when(registrationServiceClient.getSession(any(), any())) - .thenReturn(CompletableFuture.completedFuture(Optional.of(new RegistrationSession(NUMBER, true)))); - when(accountsManager.create(any(), any(), any(), any(), any())) - .thenReturn(mock(Account.class)); - - final Invocation.Builder request = resources.getJerseyTest() - .target("/v1/registration") - .request() - .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD)); - try (Response response = request.post(Entity.json(requestJson("sessionId")))) { - assertEquals(200, response.getStatus()); - } - } - - /** - * Valid request JSON with the give session ID and skipDeviceTransfer - */ - private static String requestJson(final String sessionId, final byte[] recoveryPassword, final boolean skipDeviceTransfer) { - final String rp = encodeRecoveryPassword(recoveryPassword); - return String.format(""" - { - "sessionId": "%s", - "recoveryPassword": "%s", - "accountAttributes": { - "recoveryPassword": "%s" - }, - "skipDeviceTransfer": %s - } - """, encodeSessionId(sessionId), rp, rp, skipDeviceTransfer); - } - - /** - * Valid request JSON with the given session ID - */ - private static String requestJson(final String sessionId) { - return requestJson(sessionId, new byte[0], false); - } - - /** - * Valid request JSON with the given Recovery Password - */ - private static String requestJsonRecoveryPassword(final byte[] recoveryPassword) { - return requestJson("", recoveryPassword, false); - } - - /** - * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.RegistrationRequest}, but that fails - * validation - */ - private static String invalidRequestJson() { - return """ - { - "sessionId": null, - "accountAttributes": {}, - "skipDeviceTransfer": false - } - """; - } - - /** - * Request JSON that cannot be marshalled into {@link org.whispersystems.textsecuregcm.entities.RegistrationRequest} - */ - private static String unprocessableJson() { - return """ - { - "sessionId": [] - } - """; - } - - private static String encodeSessionId(final String sessionId) { - return Base64.getUrlEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)); - } - - private static String encodeRecoveryPassword(final byte[] recoveryPassword) { - return Base64.getEncoder().encodeToString(recoveryPassword); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureBackupControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureBackupControllerTest.java deleted file mode 100644 index 46bd69cd3..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureBackupControllerTest.java +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.apache.commons.lang3.RandomUtils; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; -import org.whispersystems.textsecuregcm.entities.AuthCheckRequest; -import org.whispersystems.textsecuregcm.entities.AuthCheckResponse; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.MutableClock; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class SecureBackupControllerTest { - - private static final UUID USER_1 = UUID.randomUUID(); - - private static final UUID USER_2 = UUID.randomUUID(); - - private static final UUID USER_3 = UUID.randomUUID(); - - private static final String E164_VALID = "+18005550123"; - - private static final String E164_INVALID = "1(800)555-0123"; - - private static final byte[] SECRET = RandomUtils.nextBytes(32); - - private static final SecureBackupServiceConfiguration CFG = MockUtils.buildMock( - SecureBackupServiceConfiguration.class, - cfg -> Mockito.when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(SECRET) - ); - - private static final MutableClock CLOCK = MockUtils.mutableClock(0); - - private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR = - SecureBackupController.credentialsGenerator(CFG, CLOCK); - - private static final AccountsManager ACCOUNTS_MANAGER = Mockito.mock(AccountsManager.class); - - private static final SecureBackupController CONTROLLER = - new SecureBackupController(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER); - - private static final ResourceExtension RESOURCES = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(CONTROLLER) - .build(); - - @BeforeAll - public static void before() throws Exception { - Mockito.when(ACCOUNTS_MANAGER.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1))); - } - - @Test - public void testOneMatch() throws Exception { - validate(Map.of( - token(USER_1, day(1)), AuthCheckResponse.Result.MATCH, - token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH, - token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH - ), day(2)); - } - - @Test - public void testNoMatch() throws Exception { - validate(Map.of( - token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH, - token(USER_3, day(1)), AuthCheckResponse.Result.NO_MATCH - ), day(2)); - } - - @Test - public void testEmptyInput() throws Exception { - validate(Collections.emptyMap(), day(2)); - } - - @Test - public void testSomeInvalid() throws Exception { - final String fakeToken = token(USER_3, day(1)).replaceAll(USER_3.toString(), USER_2.toString()); - validate(Map.of( - token(USER_1, day(1)), AuthCheckResponse.Result.MATCH, - token(USER_2, day(1)), AuthCheckResponse.Result.NO_MATCH, - fakeToken, AuthCheckResponse.Result.INVALID - ), day(2)); - } - - @Test - public void testSomeExpired() throws Exception { - validate(Map.of( - token(USER_1, day(100)), AuthCheckResponse.Result.MATCH, - token(USER_2, day(100)), AuthCheckResponse.Result.NO_MATCH, - token(USER_3, day(10)), AuthCheckResponse.Result.INVALID, - token(USER_3, day(20)), AuthCheckResponse.Result.INVALID - ), day(110)); - } - - @Test - public void testSomeHaveNewerVersions() throws Exception { - validate(Map.of( - token(USER_1, day(10)), AuthCheckResponse.Result.INVALID, - token(USER_1, day(20)), AuthCheckResponse.Result.MATCH, - token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH, - token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH, - token(USER_3, day(10)), AuthCheckResponse.Result.INVALID - ), day(25)); - } - - private static void validate( - final Map expected, - final long nowMillis) throws Exception { - CLOCK.setTimeMillis(nowMillis); - final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet())); - final AuthCheckResponse response = CONTROLLER.authCheck(request); - assertEquals(expected, response.matches()); - } - - @Test - public void testHttpResponseCodeSuccess() throws Exception { - final Map expected = Map.of( - token(USER_1, day(10)), AuthCheckResponse.Result.INVALID, - token(USER_1, day(20)), AuthCheckResponse.Result.MATCH, - token(USER_2, day(10)), AuthCheckResponse.Result.NO_MATCH, - token(USER_3, day(20)), AuthCheckResponse.Result.NO_MATCH, - token(USER_3, day(10)), AuthCheckResponse.Result.INVALID - ); - - CLOCK.setTimeMillis(day(25)); - - final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet())); - - final Response response = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(in, MediaType.APPLICATION_JSON)); - - try (response) { - final AuthCheckResponse res = response.readEntity(AuthCheckResponse.class); - assertEquals(200, response.getStatus()); - assertEquals(expected, res.matches()); - } - } - - @Test - public void testHttpResponseCodeWhenInvalidNumber() throws Exception { - final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList("1")); - final Response response = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(in, MediaType.APPLICATION_JSON)); - - try (response) { - assertEquals(422, response.getStatus()); - } - } - - @Test - public void testHttpResponseCodeWhenTooManyTokens() throws Exception { - final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of( - "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" - )); - final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of( - "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11" - )); - final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList()); - - final Response responseOkay = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(inOkay, MediaType.APPLICATION_JSON)); - - final Response responseError1 = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON)); - - final Response responseError2 = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON)); - - try (responseOkay; responseError1; responseError2) { - assertEquals(200, responseOkay.getStatus()); - assertEquals(422, responseError1.getStatus()); - assertEquals(422, responseError2.getStatus()); - } - } - - @Test - public void testHttpResponseCodeWhenPasswordsMissing() throws Exception { - final Response response = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(""" - { - "number": "123" - } - """, MediaType.APPLICATION_JSON)); - - try (response) { - assertEquals(422, response.getStatus()); - } - } - - @Test - public void testHttpResponseCodeWhenNumberMissing() throws Exception { - final Response response = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(""" - { - "passwords": ["aaa:bbb"] - } - """, MediaType.APPLICATION_JSON)); - - try (response) { - assertEquals(422, response.getStatus()); - } - } - - @Test - public void testHttpResponseCodeWhenExtraFields() throws Exception { - final Response response = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity(""" - { - "number": "+18005550123", - "passwords": ["aaa:bbb"], - "unexpected": "value" - } - """, MediaType.APPLICATION_JSON)); - - try (response) { - assertEquals(200, response.getStatus()); - } - } - - @Test - public void testHttpResponseCodeWhenNotAJson() throws Exception { - final Response response = RESOURCES.getJerseyTest() - .target("/v1/backup/auth/check") - .request() - .post(Entity.entity("random text", MediaType.APPLICATION_JSON)); - - try (response) { - assertEquals(400, response.getStatus()); - } - } - - private static String token(final UUID uuid, final long timeMillis) { - CLOCK.setTimeMillis(timeMillis); - final ExternalServiceCredentials credentials = CREDENTIAL_GENERATOR.generateForUuid(uuid); - return credentials.username() + ":" + credentials.password(); - } - - private static long day(final int n) { - return TimeUnit.DAYS.toMillis(n); - } - - private static Account account(final UUID uuid) { - final Account a = new Account(); - a.setUuid(uuid); - return a; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java deleted file mode 100644 index d51a2580b..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ /dev/null @@ -1,925 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.util.AttributeValues.b; -import static org.whispersystems.textsecuregcm.util.AttributeValues.n; -import static org.whispersystems.textsecuregcm.util.AttributeValues.s; - -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.stripe.exception.ApiException; -import com.stripe.model.PaymentIntent; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.time.Clock; -import java.time.Instant; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.function.Predicate; -import java.util.stream.Stream; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.server.ServerProperties; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.badges.BadgeTranslator; -import org.whispersystems.textsecuregcm.badges.LevelTranslator; -import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; -import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; -import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetLevelsResponse; -import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSubscriptionConfigurationResponse; -import org.whispersystems.textsecuregcm.entities.Badge; -import org.whispersystems.textsecuregcm.entities.BadgeSvg; -import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; -import org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager; -import org.whispersystems.textsecuregcm.storage.SubscriptionManager; -import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; -import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; -import org.whispersystems.textsecuregcm.subscriptions.StripeManager; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorManager; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -@ExtendWith(DropwizardExtensionsSupport.class) -class SubscriptionControllerTest { - - private static final Clock CLOCK = mock(Clock.class); - - private static final YAMLMapper YAML_MAPPER = new YAMLMapper(); - - static { - YAML_MAPPER.registerModule(new JavaTimeModule()); - } - - private static final SubscriptionConfiguration SUBSCRIPTION_CONFIG = ConfigHelper.getSubscriptionConfig(); - private static final OneTimeDonationConfiguration ONETIME_CONFIG = ConfigHelper.getOneTimeConfig(); - private static final SubscriptionManager SUBSCRIPTION_MANAGER = mock(SubscriptionManager.class); - private static final StripeManager STRIPE_MANAGER = mock(StripeManager.class); - private static final BraintreeManager BRAINTREE_MANAGER = mock(BraintreeManager.class); - private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class); - - static { - // this behavior is required by the SubscriptionController constructor - List.of(STRIPE_MANAGER, BRAINTREE_MANAGER) - .forEach(manager -> { - when(manager.supportsPaymentMethod(any())) - .thenCallRealMethod(); - }); - when(STRIPE_MANAGER.getSupportedCurrencies()) - .thenReturn(Set.of("usd", "jpy", "bif")); - when(BRAINTREE_MANAGER.getSupportedCurrencies()) - .thenReturn(Set.of("usd", "jpy")); - } - - private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class); - private static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class); - private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); - private static final LevelTranslator LEVEL_TRANSLATOR = mock(LevelTranslator.class); - private static final SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController( - CLOCK, SUBSCRIPTION_CONFIG, ONETIME_CONFIG, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, - ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR, LEVEL_TRANSLATOR); - private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() - .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(CompletionExceptionMapper.class) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(Set.of( - AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(SUBSCRIPTION_CONTROLLER) - .build(); - - @BeforeEach - void setUp() { - reset(CLOCK, SUBSCRIPTION_MANAGER, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, - BADGE_TRANSLATOR, LEVEL_TRANSLATOR); - - when(STRIPE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.STRIPE); - when(BRAINTREE_MANAGER.getProcessor()).thenReturn(SubscriptionProcessor.BRAINTREE); - } - - @Test - void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() { - when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true); - final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") - .request() - .post(Entity.json(""" - { - "currency": "USD", - "amount": 249, - "level": null - } - """)); - assertThat(response.getStatus()).isEqualTo(400); - assertThat(response.hasEntity()).isTrue(); - assertThat(response.readEntity(Map.class)) - .isNotNull() - .containsAllEntriesOf(Map.of( - "error", "amount_below_currency_minimum", - "minimum", "2.50" - )); - } - - @Test - void testCreateBoostPaymentIntentLevelAmountMismatch() { - final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") - .request() - .post(Entity.json(""" - { - "currency": "USD", - "amount": 25, - "level": 100 - } - """ - )); - assertThat(response.getStatus()).isEqualTo(409); - } - - @Test - void testCreateBoostPaymentIntent() { - when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT)); - when(STRIPE_MANAGER.supportsCurrency("usd")).thenReturn(true); - - String clientSecret = "some_client_secret"; - when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret); - - final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/create") - .request() - .post(Entity.json("{\"currency\": \"USD\", \"amount\": 300, \"level\": null}")); - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void createBoostReceiptInvalid() { - final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") - .request() - // invalid, request body should have receiptCredentialRequest - .post(Entity.json("{\"paymentIntentId\": \"foo\"}")); - assertThat(response.getStatus()).isEqualTo(422); - } - - @Test - void createBoostReceiptNoRequest() { - final Response response = RESOURCE_EXTENSION.target("/v1/subscription/boost/receipt_credentials") - .request() - .post(Entity.json("")); - assertThat(response.getStatus()).isEqualTo(422); - } - - @Nested - class SetSubscriptionLevel { - - private final long levelId = 5L; - private final String currency = "jpy"; - - private String subscriberId; - - @BeforeEach - void setUp() { - when(CLOCK.instant()).thenReturn(Instant.now()); - - final byte[] subscriberUserAndKey = new byte[32]; - Arrays.fill(subscriberUserAndKey, (byte) 1); - subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); - - final ProcessorCustomer processorCustomer = new ProcessorCustomer("testCustomerId", SubscriptionProcessor.STRIPE); - - final Map dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]), - SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID, b(processorCustomer.toDynamoBytes()) - ); - final SubscriptionManager.Record record = SubscriptionManager.Record.from( - Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem); - when(SUBSCRIPTION_MANAGER.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any())) - .thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); - - when(SUBSCRIPTION_MANAGER.subscriptionCreated(any(), any(), any(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(null)); - } - - @Test - void success() { - when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(mock(SubscriptionProcessorManager.SubscriptionId.class))); - - final String level = String.valueOf(levelId); - final String idempotencyKey = UUID.randomUUID().toString(); - final Response response = RESOURCE_EXTENSION.target( - String.format("/v1/subscription/%s/level/%s/%s/%s", subscriberId, level, currency, idempotencyKey)) - .request() - .put(Entity.json("")); - - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void missingCustomerId() { - final byte[] subscriberUserAndKey = new byte[32]; - Arrays.fill(subscriberUserAndKey, (byte) 1); - subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); - - final Map dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]), - SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()) - // missing processor:customer field - ); - final SubscriptionManager.Record record = SubscriptionManager.Record.from( - Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem); - when(SUBSCRIPTION_MANAGER.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any())) - .thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); - - final String level = String.valueOf(levelId); - final String idempotencyKey = UUID.randomUUID().toString(); - final Response response = RESOURCE_EXTENSION.target( - String.format("/v1/subscription/%s/level/%s/%s/%s", subscriberId, level, currency, idempotencyKey)) - .request() - .put(Entity.json("")); - - assertThat(response.getStatus()).isEqualTo(409); - } - - @Test - void stripePaymentIntentRequiresAction() { - final ApiException stripeException = new ApiException("Payment intent requires action", - UUID.randomUUID().toString(), "subscription_payment_intent_requires_action", 400, new Exception()); - when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.failedFuture(new CompletionException(stripeException))); - - final String level = String.valueOf(levelId); - final String idempotencyKey = UUID.randomUUID().toString(); - final Response response = RESOURCE_EXTENSION.target( - String.format("/v1/subscription/%s/level/%s/%s/%s", subscriberId, level, currency, idempotencyKey)) - .request() - .put(Entity.json("")); - - assertThat(response.getStatus()).isEqualTo(400); - - assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelErrorResponse.class)) - .satisfies(errorResponse -> { - assertThat(errorResponse.getErrors()) - .anySatisfy(error -> { - assertThat(error.getType()).isEqualTo( - SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION); - }); - }); - } - } - - @Test - void createSubscriber() { - when(CLOCK.instant()).thenReturn(Instant.now()); - - // basic create - final byte[] subscriberUserAndKey = new byte[32]; - Arrays.fill(subscriberUserAndKey, (byte) 1); - final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); - - when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture( - SubscriptionManager.GetResult.NOT_STORED)); - - final Map dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]), - SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()) - ); - final SubscriptionManager.Record record = SubscriptionManager.Record.from( - Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem); - when(SUBSCRIPTION_MANAGER.create(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(record)); - - final Response createResponse = RESOURCE_EXTENSION.target(String.format("/v1/subscription/%s", subscriberId)) - .request() - .put(Entity.json("")); - assertThat(createResponse.getStatus()).isEqualTo(200); - - // creating should be idempotent - when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture( - SubscriptionManager.GetResult.found(record))); - when(SUBSCRIPTION_MANAGER.accessedAt(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); - - final Response idempotentCreateResponse = RESOURCE_EXTENSION.target( - String.format("/v1/subscription/%s", subscriberId)) - .request() - .put(Entity.json("")); - assertThat(idempotentCreateResponse.getStatus()).isEqualTo(200); - - // when the manager returns `null`, it means there was a password mismatch from the storage layer `create`. - // this could happen if there is a race between two concurrent `create` requests for the same user ID - when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture( - SubscriptionManager.GetResult.NOT_STORED)); - when(SUBSCRIPTION_MANAGER.create(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null)); - - final Response managerCreateNullResponse = RESOURCE_EXTENSION.target( - String.format("/v1/subscription/%s", subscriberId)) - .request() - .put(Entity.json("")); - assertThat(managerCreateNullResponse.getStatus()).isEqualTo(403); - - final byte[] subscriberUserAndMismatchedKey = new byte[32]; - Arrays.fill(subscriberUserAndMismatchedKey, 0, 16, (byte) 1); - Arrays.fill(subscriberUserAndMismatchedKey, 16, 32, (byte) 2); - final String mismatchedSubscriberId = Base64.getEncoder().encodeToString(subscriberUserAndMismatchedKey); - - // a password mismatch for an existing record - when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture( - SubscriptionManager.GetResult.PASSWORD_MISMATCH)); - - final Response passwordMismatchResponse = RESOURCE_EXTENSION.target( - String.format("/v1/subscription/%s", mismatchedSubscriberId)) - .request() - .put(Entity.json("")); - - assertThat(passwordMismatchResponse.getStatus()).isEqualTo(403); - - // invalid request data is a 404 - final byte[] malformedUserAndKey = new byte[16]; - Arrays.fill(malformedUserAndKey, (byte) 1); - final String malformedUserId = Base64.getEncoder().encodeToString(malformedUserAndKey); - - final Response malformedUserAndKeyResponse = RESOURCE_EXTENSION.target( - String.format("/v1/subscription/%s", malformedUserId)) - .request() - .put(Entity.json("")); - - assertThat(malformedUserAndKeyResponse.getStatus()).isEqualTo(404); - } - - @Test - void createPaymentMethod() { - final byte[] subscriberUserAndKey = new byte[32]; - Arrays.fill(subscriberUserAndKey, (byte) 1); - final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); - - when(CLOCK.instant()).thenReturn(Instant.now()); - when(SUBSCRIPTION_MANAGER.get(any(), any())).thenReturn(CompletableFuture.completedFuture( - SubscriptionManager.GetResult.NOT_STORED)); - - final Map dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]), - SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()) - ); - final SubscriptionManager.Record record = SubscriptionManager.Record.from( - Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem); - when(SUBSCRIPTION_MANAGER.create(any(), any(), any(Instant.class))) - .thenReturn(CompletableFuture.completedFuture(record)); - - final Response createSubscriberResponse = RESOURCE_EXTENSION - .target(String.format("/v1/subscription/%s", subscriberId)) - .request() - .put(Entity.json("")); - - assertThat(createSubscriberResponse.getStatus()).isEqualTo(200); - - when(SUBSCRIPTION_MANAGER.get(any(), any())) - .thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); - - final String customerId = "some-customer-id"; - final ProcessorCustomer customer = new ProcessorCustomer( - customerId, SubscriptionProcessor.STRIPE); - when(STRIPE_MANAGER.createCustomer(any())) - .thenReturn(CompletableFuture.completedFuture(customer)); - - final Map dynamoItemWithProcessorCustomer = new HashMap<>(dynamoItem); - dynamoItemWithProcessorCustomer.put(SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID, - b(new ProcessorCustomer(customerId, SubscriptionProcessor.STRIPE).toDynamoBytes())); - final SubscriptionManager.Record recordWithCustomerId = SubscriptionManager.Record.from(record.user, - dynamoItemWithProcessorCustomer); - - when(SUBSCRIPTION_MANAGER.setProcessorAndCustomerId(any(SubscriptionManager.Record.class), any(), - any(Instant.class))) - .thenReturn(CompletableFuture.completedFuture(recordWithCustomerId)); - - final String clientSecret = "some-client-secret"; - when(STRIPE_MANAGER.createPaymentMethodSetupToken(customerId)) - .thenReturn(CompletableFuture.completedFuture(clientSecret)); - - final SubscriptionController.CreatePaymentMethodResponse createPaymentMethodResponse = RESOURCE_EXTENSION - .target(String.format("/v1/subscription/%s/create_payment_method", subscriberId)) - .request() - .post(Entity.json("")) - .readEntity(SubscriptionController.CreatePaymentMethodResponse.class); - - assertThat(createPaymentMethodResponse.processor()).isEqualTo(SubscriptionProcessor.STRIPE); - assertThat(createPaymentMethodResponse.clientSecret()).isEqualTo(clientSecret); - - } - - @Test - void setSubscriptionLevelMissingProcessorCustomer() { - // set up record - final byte[] subscriberUserAndKey = new byte[32]; - Arrays.fill(subscriberUserAndKey, (byte) 1); - final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); - - final Map dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]), - SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()) - ); - final SubscriptionManager.Record record = SubscriptionManager.Record.from( - Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem); - when(SUBSCRIPTION_MANAGER.create(any(), any(), any(Instant.class))) - .thenReturn(CompletableFuture.completedFuture(record)); - - // set up mocks - when(CLOCK.instant()).thenReturn(Instant.now()); - when(SUBSCRIPTION_MANAGER.get(any(), any())) - .thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); - - final Response response = RESOURCE_EXTENSION - .target(String.format("/v1/subscription/%s/level/%d/%s/%s", subscriberId, 5, "usd", "abcd")) - .request() - .put(Entity.json("")); - - assertThat(response.getStatus()).isEqualTo(409); - } - - @Test - void setSubscriptionLevel() { - // set up record - final byte[] subscriberUserAndKey = new byte[32]; - Arrays.fill(subscriberUserAndKey, (byte) 1); - final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); - - final String customerId = "customer"; - final Map dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]), - SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID, - b(new ProcessorCustomer(customerId, SubscriptionProcessor.BRAINTREE).toDynamoBytes()) - ); - final SubscriptionManager.Record record = SubscriptionManager.Record.from( - Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem); - when(SUBSCRIPTION_MANAGER.create(any(), any(), any(Instant.class))) - .thenReturn(CompletableFuture.completedFuture(record)); - - // set up mocks - when(CLOCK.instant()).thenReturn(Instant.now()); - when(SUBSCRIPTION_MANAGER.get(any(), any())) - .thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); - - when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(new SubscriptionProcessorManager.SubscriptionId( - "subscription"))); - when(SUBSCRIPTION_MANAGER.subscriptionCreated(any(), any(), any(), anyLong())) - .thenReturn(CompletableFuture.completedFuture(null)); - - final long level = 5; - final Response response = RESOURCE_EXTENSION - .target(String.format("/v1/subscription/%s/level/%d/%s/%s", subscriberId, level, "usd", "abcd")) - .request() - .put(Entity.json("")); - - verify(BRAINTREE_MANAGER).createSubscription(eq(customerId), eq("M1"), eq(level), eq(0L)); - verifyNoMoreInteractions(BRAINTREE_MANAGER); - - assertThat(response.getStatus()).isEqualTo(200); - - assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class)) - .extracting(SubscriptionController.SetSubscriptionLevelSuccessResponse::getLevel) - .isEqualTo(level); - } - - @ParameterizedTest - @MethodSource - void setSubscriptionLevelExistingSubscription(final String existingCurrency, final long existingLevel, - final String requestCurrency, final long requestLevel, final boolean expectUpdate) { - - // set up record - final byte[] subscriberUserAndKey = new byte[32]; - Arrays.fill(subscriberUserAndKey, (byte) 1); - final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey); - - final String customerId = "customer"; - final String existingSubscriptionId = "existingSubscription"; - final Map dynamoItem = Map.of(SubscriptionManager.KEY_PASSWORD, b(new byte[16]), - SubscriptionManager.KEY_CREATED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()), - SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID, - b(new ProcessorCustomer(customerId, SubscriptionProcessor.BRAINTREE).toDynamoBytes()), - SubscriptionManager.KEY_SUBSCRIPTION_ID, s(existingSubscriptionId) - ); - final SubscriptionManager.Record record = SubscriptionManager.Record.from( - Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem); - when(SUBSCRIPTION_MANAGER.create(any(), any(), any(Instant.class))) - .thenReturn(CompletableFuture.completedFuture(record)); - - // set up mocks - when(CLOCK.instant()).thenReturn(Instant.now()); - when(SUBSCRIPTION_MANAGER.get(any(), any())) - .thenReturn(CompletableFuture.completedFuture(SubscriptionManager.GetResult.found(record))); - - final Object subscriptionObj = new Object(); - when(BRAINTREE_MANAGER.getSubscription(any())) - .thenReturn(CompletableFuture.completedFuture(subscriptionObj)); - when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj)) - .thenReturn(CompletableFuture.completedFuture( - new SubscriptionProcessorManager.LevelAndCurrency(existingLevel, existingCurrency))); - final String updatedSubscriptionId = "updatedSubscriptionId"; - - if (expectUpdate) { - when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString())) - .thenReturn(CompletableFuture.completedFuture(new SubscriptionProcessorManager.SubscriptionId( - updatedSubscriptionId))); - when(SUBSCRIPTION_MANAGER.subscriptionLevelChanged(any(), any(), anyLong(), anyString())) - .thenReturn(CompletableFuture.completedFuture(null)); - } - - final String idempotencyKey = "abcd"; - final Response response = RESOURCE_EXTENSION - .target(String.format("/v1/subscription/%s/level/%d/%s/%s", subscriberId, requestLevel, requestCurrency, - idempotencyKey)) - .request() - .put(Entity.json("")); - - verify(BRAINTREE_MANAGER).getSubscription(any()); - verify(BRAINTREE_MANAGER).getLevelAndCurrencyForSubscription(any()); - - if (expectUpdate) { - verify(BRAINTREE_MANAGER).updateSubscription(any(), any(), eq(requestLevel), eq(idempotencyKey)); - verify(SUBSCRIPTION_MANAGER).subscriptionLevelChanged(any(), any(), eq(requestLevel), eq(updatedSubscriptionId)); - } - - verifyNoMoreInteractions(BRAINTREE_MANAGER); - - assertThat(response.getStatus()).isEqualTo(200); - - assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class)) - .extracting(SubscriptionController.SetSubscriptionLevelSuccessResponse::getLevel) - .isEqualTo(requestLevel); - } - - static Stream setSubscriptionLevelExistingSubscription() { - return Stream.of( - Arguments.of("usd", 5, "usd", 5, false), - Arguments.of("usd", 5, "jpy", 5, true), - Arguments.of("usd", 5, "usd", 15, true), - Arguments.of("usd", 5, "jpy", 15, true) - ); - } - - @Test - void getSubscriptionConfiguration() { - - when(BADGE_TRANSLATOR.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(BADGE_TRANSLATOR.translate(any(), eq("B2"))).thenReturn(new Badge("B2", "cat2", "name2", "desc2", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(BADGE_TRANSLATOR.translate(any(), eq("B3"))).thenReturn(new Badge("B3", "cat3", "name3", "desc3", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(BADGE_TRANSLATOR.translate(any(), eq("BOOST"))).thenReturn(new Badge("BOOST", "boost1", "boost1", "boost1", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(BADGE_TRANSLATOR.translate(any(), eq("GIFT"))).thenReturn(new Badge("GIFT", "gift1", "gift1", "gift1", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(LEVEL_TRANSLATOR.translate(any(), eq("B1"))).thenReturn("Z1"); - when(LEVEL_TRANSLATOR.translate(any(), eq("B2"))).thenReturn("Z2"); - when(LEVEL_TRANSLATOR.translate(any(), eq("B3"))).thenReturn("Z3"); - - GetSubscriptionConfigurationResponse response = RESOURCE_EXTENSION.target("/v1/subscription/configuration") - .request() - .get(GetSubscriptionConfigurationResponse.class); - - assertThat(response.currencies()).containsKeys("usd", "jpy", "bif").satisfies(currencyMap -> { - assertThat(currencyMap).extractingByKey("usd").satisfies(currency -> { - assertThat(currency.minimum()).isEqualByComparingTo( - BigDecimal.valueOf(2.5).setScale(2, RoundingMode.HALF_EVEN)); - assertThat(currency.oneTime()).isEqualTo( - Map.of("1", - List.of(BigDecimal.valueOf(5.5).setScale(2, RoundingMode.HALF_EVEN), BigDecimal.valueOf(6), - BigDecimal.valueOf(7), BigDecimal.valueOf(8), - BigDecimal.valueOf(9), BigDecimal.valueOf(10)), "100", - List.of(BigDecimal.valueOf(20)))); - assertThat(currency.subscription()).isEqualTo( - Map.of("5", BigDecimal.valueOf(5), "15", BigDecimal.valueOf(15), "35", BigDecimal.valueOf(35))); - assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL")); - }); - - assertThat(currencyMap).extractingByKey("jpy").satisfies(currency -> { - assertThat(currency.minimum()).isEqualByComparingTo( - BigDecimal.valueOf(250)); - assertThat(currency.oneTime()).isEqualTo( - Map.of("1", - List.of(BigDecimal.valueOf(550), BigDecimal.valueOf(600), - BigDecimal.valueOf(700), BigDecimal.valueOf(800), - BigDecimal.valueOf(900), BigDecimal.valueOf(1000)), "100", - List.of(BigDecimal.valueOf(2000)))); - assertThat(currency.subscription()).isEqualTo( - Map.of("5", BigDecimal.valueOf(500), "15", BigDecimal.valueOf(1500), "35", BigDecimal.valueOf(3500))); - assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD", "PAYPAL")); - }); - - assertThat(currencyMap).extractingByKey("bif").satisfies(currency -> { - assertThat(currency.minimum()).isEqualByComparingTo( - BigDecimal.valueOf(2500)); - assertThat(currency.oneTime()).isEqualTo( - Map.of("1", - List.of(BigDecimal.valueOf(5500), BigDecimal.valueOf(6000), - BigDecimal.valueOf(7000), BigDecimal.valueOf(8000), - BigDecimal.valueOf(9000), BigDecimal.valueOf(10000)), "100", - List.of(BigDecimal.valueOf(20000)))); - assertThat(currency.subscription()).isEqualTo( - Map.of("5", BigDecimal.valueOf(5000), "15", BigDecimal.valueOf(15000), "35", BigDecimal.valueOf(35000))); - assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of("CARD")); - }); - }); - - assertThat(response.levels()).containsKeys("1", "5", "15", "35", "100").satisfies(levelsMap -> { - assertThat(levelsMap).extractingByKey("1").satisfies(level -> { - assertThat(level.name()).isEqualTo("boost1"); // level name is the same as badge name - assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { - assertThat(badge.getId()).isEqualTo("BOOST"); - assertThat(badge.getName()).isEqualTo("boost1"); - }); - }); - - assertThat(levelsMap).extractingByKey("100").satisfies(level -> { - assertThat(level.name()).isEqualTo("gift1"); // level name is the same as badge name - assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { - assertThat(badge.getId()).isEqualTo("GIFT"); - assertThat(badge.getName()).isEqualTo("gift1"); - }); - }); - - assertThat(levelsMap).extractingByKey("5").satisfies(level -> { - assertThat(level.name()).isEqualTo("Z1"); - assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { - assertThat(badge.getId()).isEqualTo("B1"); - assertThat(badge.getName()).isEqualTo("name1"); - }); - }); - - assertThat(levelsMap).extractingByKey("15").satisfies(level -> { - assertThat(level.name()).isEqualTo("Z2"); - assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { - assertThat(badge.getId()).isEqualTo("B2"); - assertThat(badge.getName()).isEqualTo("name2"); - }); - }); - - assertThat(levelsMap).extractingByKey("35").satisfies(level -> { - assertThat(level.name()).isEqualTo("Z3"); - assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge).satisfies(badge -> { - assertThat(badge.getId()).isEqualTo("B3"); - assertThat(badge.getName()).isEqualTo("name3"); - }); - }); - }); - - // check the badge vs purchasable badge fields - // subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration` - Map genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration") - .request() - .get(Map.class); - - assertThat(genericResponse.get("levels")).satisfies(levels -> { - final Set oneTimeLevels = Set.of("1", "100"); - oneTimeLevels.forEach(oneTimeLevel -> { - assertThat((Map>>) levels).extractingByKey(oneTimeLevel) - .satisfies(level -> { - assertThat(level.get("badge")).containsKeys("duration"); - }); - }); - - ((Map) levels).keySet().stream() - .filter(Predicate.not(oneTimeLevels::contains)) - .forEach(subscriptionLevel -> { - assertThat((Map>>) levels).extractingByKey(subscriptionLevel) - .satisfies(level -> { - assertThat(level.get("badge")).doesNotContainKeys("duration"); - }); - }); - }); - } - - @Test - void testGetBoostAmounts() { - final Map boostAmounts = RESOURCE_EXTENSION.target("/v1/subscription/boost/amounts") - .request() - .get(Map.class); - - assertThat(boostAmounts).isEqualTo(Map.of( - "USD", List.of(5.50, 6, 7, 8, 9, 10), - "JPY", List.of(550, 600, 700, 800, 900, 1000), - "BIF", List.of(5500, 6000, 7000, 8000, 9000, 10000) - )); - - final Map giftAmounts = RESOURCE_EXTENSION.target("/v1/subscription/boost/amounts/gift") - .request() - .get(Map.class); - - assertThat(giftAmounts).isEqualTo(Map.of( - "USD", 20, - "JPY", 2000, - "BIF", 20000 - )); - } - - @Test - void getLevels() { - when(BADGE_TRANSLATOR.translate(any(), eq("B1"))).thenReturn(new Badge("B1", "cat1", "name1", "desc1", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(BADGE_TRANSLATOR.translate(any(), eq("B2"))).thenReturn(new Badge("B2", "cat2", "name2", "desc2", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(BADGE_TRANSLATOR.translate(any(), eq("B3"))).thenReturn(new Badge("B3", "cat3", "name3", "desc3", - List.of("l", "m", "h", "x", "xx", "xxx"), "SVG", - List.of(new BadgeSvg("sl", "sd"), new BadgeSvg("ml", "md"), new BadgeSvg("ll", "ld")))); - when(LEVEL_TRANSLATOR.translate(any(), eq("B1"))).thenReturn("Z1"); - when(LEVEL_TRANSLATOR.translate(any(), eq("B2"))).thenReturn("Z2"); - when(LEVEL_TRANSLATOR.translate(any(), eq("B3"))).thenReturn("Z3"); - - GetLevelsResponse response = RESOURCE_EXTENSION.target("/v1/subscription/levels") - .request() - .get(GetLevelsResponse.class); - - assertThat(response.getLevels()).containsKeys(5L, 15L, 35L).satisfies(longLevelMap -> { - assertThat(longLevelMap).extractingByKey(5L).satisfies(level -> { - assertThat(level.getName()).isEqualTo("Z1"); - assertThat(level.getBadge().getId()).isEqualTo("B1"); - assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { - assertThat(price).isEqualTo("5"); - }); - }); - assertThat(longLevelMap).extractingByKey(15L).satisfies(level -> { - assertThat(level.getName()).isEqualTo("Z2"); - assertThat(level.getBadge().getId()).isEqualTo("B2"); - assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { - assertThat(price).isEqualTo("15"); - }); - }); - assertThat(longLevelMap).extractingByKey(35L).satisfies(level -> { - assertThat(level.getName()).isEqualTo("Z3"); - assertThat(level.getBadge().getId()).isEqualTo("B3"); - assertThat(level.getCurrencies()).containsKeys("USD").extractingByKey("USD").satisfies(price -> { - assertThat(price).isEqualTo("35"); - }); - }); - }); - } - - /** - * Encapsulates {@code static} configuration, to keep the class header simpler and avoid illegal forward references - */ - private record ConfigHelper() { - - private static SubscriptionConfiguration getSubscriptionConfig() { - return readValue(SUBSCRIPTION_CONFIG_YAML, SubscriptionConfiguration.class); - } - - private static OneTimeDonationConfiguration getOneTimeConfig() { - return readValue(ONETIME_CONFIG_YAML, OneTimeDonationConfiguration.class); - } - - private static T readValue(String yaml, Class type) { - try { - return YAML_MAPPER.readValue(yaml, type); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private static final String SUBSCRIPTION_CONFIG_YAML = """ - badgeGracePeriod: P15D - levels: - 5: - badge: B1 - prices: - usd: - amount: '5' - processorIds: - STRIPE: R1 - BRAINTREE: M1 - jpy: - amount: '500' - processorIds: - STRIPE: Q1 - BRAINTREE: N1 - bif: - amount: '5000' - processorIds: - STRIPE: S1 - BRAINTREE: O1 - 15: - badge: B2 - prices: - usd: - amount: '15' - processorIds: - STRIPE: R2 - BRAINTREE: M2 - jpy: - amount: '1500' - processorIds: - STRIPE: Q2 - BRAINTREE: N2 - bif: - amount: '15000' - processorIds: - STRIPE: S2 - BRAINTREE: O2 - 35: - badge: B3 - prices: - usd: - amount: '35' - processorIds: - STRIPE: R3 - BRAINTREE: M3 - jpy: - amount: '3500' - processorIds: - STRIPE: Q3 - BRAINTREE: N3 - bif: - amount: '35000' - processorIds: - STRIPE: S3 - BRAINTREE: O3 - """; - - private static final String ONETIME_CONFIG_YAML = """ - boost: - level: 1 - expiration: P45D - badge: BOOST - gift: - level: 100 - expiration: P60D - badge: GIFT - currencies: - usd: - minimum: '2.50' # fractional to test BigDecimal conversion - gift: '20' - boosts: - - '5.50' - - '6' - - '7' - - '8' - - '9' - - '10' - jpy: - minimum: '250' - gift: '2000' - boosts: - - '550' - - '600' - - '700' - - '800' - - '900' - - '1000' - bif: - minimum: '2500' - gift: '20000' - boosts: - - '5500' - - '6000' - - '7000' - - '8000' - - '9000' - - '10000' - """; - - } - - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClientTest.java deleted file mode 100644 index 85f3c5c60..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/currency/CoinMarketCapClientTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import com.fasterxml.jackson.core.JsonProcessingException; -import java.io.IOException; -import java.math.BigDecimal; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class CoinMarketCapClientTest { - - private static final String RESPONSE_JSON = """ - { - "status": { - "timestamp": "2022-11-09T17:15:06.356Z", - "error_code": 0, - "error_message": null, - "elapsed": 41, - "credit_count": 1, - "notice": null - }, - "data": { - "id": 7878, - "symbol": "MOB", - "name": "MobileCoin", - "amount": 1, - "last_updated": "2022-11-09T17:14:00.000Z", - "quote": { - "USD": { - "price": 0.6625319895827952, - "last_updated": "2022-11-09T17:14:00.000Z" - } - } - } - } - """; - - @Test - void parseResponse() throws JsonProcessingException { - final CoinMarketCapClient.CoinMarketCapResponse parsedResponse = CoinMarketCapClient.parseResponse(RESPONSE_JSON); - - assertEquals(7878, parsedResponse.priceConversionResponse().id()); - assertEquals("MOB", parsedResponse.priceConversionResponse().symbol()); - - final Map quote = - parsedResponse.priceConversionResponse().quote(); - - assertEquals(1, quote.size()); - assertEquals(new BigDecimal("0.6625319895827952"), quote.get("USD").price()); - } - - @Test - void extractConversionRate() throws IOException { - final CoinMarketCapClient.CoinMarketCapResponse parsedResponse = CoinMarketCapClient.parseResponse(RESPONSE_JSON); - - assertEquals(new BigDecimal("0.6625319895827952"), CoinMarketCapClient.extractConversionRate(parsedResponse, "USD")); - assertThrows(IOException.class, () -> CoinMarketCapClient.extractConversionRate(parsedResponse, "CAD")); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java deleted file mode 100644 index cd11a1858..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java +++ /dev/null @@ -1,226 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.math.BigDecimal; -import java.time.Clock; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; - -class CurrencyConversionManagerTest { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - @Test - void testCurrencyCalculations() throws IOException { - FixerClient fixerClient = mock(FixerClient.class); - CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); - when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( - "EUR", new BigDecimal("0.822876"), - "FJD", new BigDecimal("2.0577"), - "FKP", new BigDecimal("0.743446") - )); - - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), - List.of("FOO"), Clock.systemUTC()); - - manager.updateCacheIfNecessary(); - - CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); - - assertThat(conversions.getCurrencies().size()).isEqualTo(1); - assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO"); - assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4); - assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(new BigDecimal("2.35")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(new BigDecimal("1.9337586")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(new BigDecimal("4.835595")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(new BigDecimal("1.7470981")); - } - - @Test - void testCurrencyCalculations_noTrailingZeros() throws IOException { - FixerClient fixerClient = mock(FixerClient.class); - CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("1.00000")); - when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( - "EUR", new BigDecimal("0.200000"), - "FJD", new BigDecimal("3.00000"), - "FKP", new BigDecimal("50.0000"), - "CAD", new BigDecimal("700.000") - )); - - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), - List.of("FOO"), Clock.systemUTC()); - - manager.updateCacheIfNecessary(); - - CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); - - assertThat(conversions.getCurrencies().size()).isEqualTo(1); - assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO"); - assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(5); - assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(new BigDecimal("1")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(new BigDecimal("0.2")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(new BigDecimal("3")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(new BigDecimal("50")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("CAD")).isEqualTo(new BigDecimal("700")); - } - - @Test - void testCurrencyCalculations_accuracy() throws IOException { - FixerClient fixerClient = mock(FixerClient.class); - CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("0.999999")); - when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( - "EUR", new BigDecimal("1.000001"), - "FJD", new BigDecimal("0.000001"), - "FKP", new BigDecimal("1") - )); - - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), - List.of("FOO"), Clock.systemUTC()); - - manager.updateCacheIfNecessary(); - - CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); - - assertThat(conversions.getCurrencies().size()).isEqualTo(1); - assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO"); - assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4); - assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(new BigDecimal("0.999999")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(new BigDecimal("0.999999999999")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(new BigDecimal("0.000000999999")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(new BigDecimal("0.999999")); - - } - - @Test - void testCurrencyCalculationsTimeoutNoRun() throws IOException { - FixerClient fixerClient = mock(FixerClient.class); - CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); - when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( - "EUR", new BigDecimal("0.822876"), - "FJD", new BigDecimal("2.0577"), - "FKP", new BigDecimal("0.743446") - )); - - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), - List.of("FOO"), Clock.systemUTC()); - - manager.updateCacheIfNecessary(); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); - - manager.updateCacheIfNecessary(); - - CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); - - assertThat(conversions.getCurrencies().size()).isEqualTo(1); - assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO"); - assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4); - assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(new BigDecimal("2.35")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(new BigDecimal("1.9337586")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(new BigDecimal("4.835595")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(new BigDecimal("1.7470981")); - } - - @Test - void testCurrencyCalculationsCoinMarketCapTimeoutWithRun() throws IOException { - FixerClient fixerClient = mock(FixerClient.class); - CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); - when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( - "EUR", new BigDecimal("0.822876"), - "FJD", new BigDecimal("2.0577"), - "FKP", new BigDecimal("0.743446") - )); - - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), - List.of("FOO"), Clock.systemUTC()); - - manager.updateCacheIfNecessary(); - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> - connection.sync().del(CurrencyConversionManager.COIN_MARKET_CAP_SHARED_CACHE_CURRENT_KEY)); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); - manager.updateCacheIfNecessary(); - - CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); - - assertThat(conversions.getCurrencies().size()).isEqualTo(1); - assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO"); - assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4); - assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(new BigDecimal("3.5")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(new BigDecimal("2.880066")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(new BigDecimal("7.20195")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(new BigDecimal("2.602061")); - } - - - @Test - void testCurrencyCalculationsFixerTimeoutWithRun() throws IOException { - FixerClient fixerClient = mock(FixerClient.class); - CoinMarketCapClient coinMarketCapClient = mock(CoinMarketCapClient.class); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("2.35")); - when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( - "EUR", new BigDecimal("0.822876"), - "FJD", new BigDecimal("2.0577"), - "FKP", new BigDecimal("0.743446") - )); - - final Instant currentTime = Instant.now().truncatedTo(ChronoUnit.MILLIS); - - final Clock clock = mock(Clock.class); - when(clock.instant()).thenReturn(currentTime); - when(clock.millis()).thenReturn(currentTime.toEpochMilli()); - - CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinMarketCapClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(), - List.of("FOO"), clock); - - manager.updateCacheIfNecessary(); - - when(coinMarketCapClient.getSpotPrice(eq("FOO"), eq("USD"))).thenReturn(new BigDecimal("3.50")); - when(fixerClient.getConversionsForBase(eq("USD"))).thenReturn(Map.of( - "EUR", new BigDecimal("0.922876"), - "FJD", new BigDecimal("2.0577"), - "FKP", new BigDecimal("0.743446") - )); - - final Instant afterFixerExpiration = currentTime.plus(CurrencyConversionManager.FIXER_REFRESH_INTERVAL).plusMillis(1); - when(clock.instant()).thenReturn(afterFixerExpiration); - when(clock.millis()).thenReturn(afterFixerExpiration.toEpochMilli()); - - manager.updateCacheIfNecessary(); - - CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow(); - - assertThat(conversions.getCurrencies().size()).isEqualTo(1); - assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO"); - assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4); - assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(new BigDecimal("2.35")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("EUR")).isEqualTo(new BigDecimal("2.1687586")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FJD")).isEqualTo(new BigDecimal("4.835595")); - assertThat(conversions.getCurrencies().get(0).getConversions().get("FKP")).isEqualTo(new BigDecimal("1.7470981")); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/currency/FixerClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/currency/FixerClientTest.java deleted file mode 100644 index b869c6e86..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/currency/FixerClientTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.whispersystems.textsecuregcm.currency; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture; - -import java.io.IOException; -import java.math.BigDecimal; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.net.http.HttpResponse.BodyHandler; -import java.util.Map; -import org.junit.jupiter.api.Test; - -public class FixerClientTest { - - @Test - public void testGetConversionsForBase() throws IOException, InterruptedException { - HttpResponse httpResponse = mock(HttpResponse.class); - when(httpResponse.statusCode()).thenReturn(200); - when(httpResponse.body()).thenReturn(jsonFixture("fixtures/fixer.res.json")); - - HttpClient httpClient = mock(HttpClient.class); - when(httpClient.send(any(HttpRequest.class), any(BodyHandler.class))).thenReturn(httpResponse); - - FixerClient fixerClient = new FixerClient(httpClient, "foo"); - Map conversions = fixerClient.getConversionsForBase("EUR"); - assertThat(conversions.get("CAD")).isEqualTo(new BigDecimal("1.560132")); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequestTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequestTest.java deleted file mode 100644 index 9ff2d12eb..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequestTest.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -import static org.junit.jupiter.api.Assertions.*; - -class AnswerChallengeRequestTest { - - @Test - void parse() throws JsonProcessingException { - { - final String pushChallengeJson = """ - { - "type": "rateLimitPushChallenge", - "challenge": "Hello I am a push challenge token" - } - """; - - final AnswerChallengeRequest answerChallengeRequest = - SystemMapper.getMapper().readValue(pushChallengeJson, AnswerChallengeRequest.class); - - assertTrue(answerChallengeRequest instanceof AnswerPushChallengeRequest); - assertEquals("Hello I am a push challenge token", - ((AnswerPushChallengeRequest) answerChallengeRequest).getChallenge()); - } - - { - final String recaptchaChallengeJson = """ - { - "type": "recaptcha", - "token": "A server-generated token", - "captcha": "The value of the solved captcha token" - } - """; - - final AnswerChallengeRequest answerChallengeRequest = - SystemMapper.getMapper().readValue(recaptchaChallengeJson, AnswerChallengeRequest.class); - - assertTrue(answerChallengeRequest instanceof AnswerRecaptchaChallengeRequest); - - assertEquals("A server-generated token", - ((AnswerRecaptchaChallengeRequest) answerChallengeRequest).getToken()); - - assertEquals("The value of the solved captcha token", - ((AnswerRecaptchaChallengeRequest) answerChallengeRequest).getCaptcha()); - } - - { - final String unrecognizedTypeJson = """ - { - "type": "unrecognized", - "token": "A server-generated token", - "captcha": "The value of the solved captcha token" - } - """; - - assertThrows(InvalidTypeIdException.class, - () -> SystemMapper.getMapper().readValue(unrecognizedTypeJson, AnswerChallengeRequest.class)); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/entities/IncomingMessageListTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/entities/IncomingMessageListTest.java deleted file mode 100644 index 28ca9abf6..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/entities/IncomingMessageListTest.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import com.fasterxml.jackson.core.JsonProcessingException; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -import static org.junit.jupiter.api.Assertions.*; - -class IncomingMessageListTest { - - @Test - void fromJson() throws JsonProcessingException { - { - final String incomingMessageListJson = """ - { - "messages": [], - "timestamp": 123456789, - "online": true, - "urgent": false - } - """; - - final IncomingMessageList incomingMessageList = - SystemMapper.getMapper().readValue(incomingMessageListJson, IncomingMessageList.class); - - assertTrue(incomingMessageList.online()); - assertFalse(incomingMessageList.urgent()); - } - - { - final String incomingMessageListJson = """ - { - "messages": [], - "timestamp": 123456789, - "online": true - } - """; - - final IncomingMessageList incomingMessageList = - SystemMapper.getMapper().readValue(incomingMessageListJson, IncomingMessageList.class); - - assertTrue(incomingMessageList.online()); - assertTrue(incomingMessageList.urgent()); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityTest.java deleted file mode 100644 index ef22f8727..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityTest.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.entities; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.Random; -import java.util.UUID; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.storage.Device; - -class OutgoingMessageEntityTest { - - @ParameterizedTest - @MethodSource - void toFromEnvelope(@Nullable final UUID sourceUuid, @Nullable final UUID updatedPni) { - final byte[] messageContent = new byte[16]; - new Random().nextBytes(messageContent); - - final long messageTimestamp = System.currentTimeMillis(); - final long serverTimestamp = messageTimestamp + 17; - - final OutgoingMessageEntity outgoingMessageEntity = new OutgoingMessageEntity(UUID.randomUUID(), - MessageProtos.Envelope.Type.CIPHERTEXT_VALUE, - messageTimestamp, - UUID.randomUUID(), - sourceUuid != null ? (int) Device.MASTER_ID : 0, - UUID.randomUUID(), - updatedPni, - messageContent, - serverTimestamp, - true, - false); - - assertEquals(outgoingMessageEntity, OutgoingMessageEntity.fromEnvelope(outgoingMessageEntity.toEnvelope())); - } - - private static Stream toFromEnvelope() { - return Stream.of( - Arguments.of(UUID.randomUUID(), UUID.randomUUID()), - Arguments.of(UUID.randomUUID(), null), - Arguments.of(null, UUID.randomUUID())); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java deleted file mode 100644 index 9f079afb6..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.experiment; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Collections; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPreRegistrationExperimentEnrollmentConfiguration; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -class ExperimentEnrollmentManagerTest { - - private DynamicExperimentEnrollmentConfiguration experimentEnrollmentConfiguration; - private DynamicPreRegistrationExperimentEnrollmentConfiguration preRegistrationExperimentEnrollmentConfiguration; - - private ExperimentEnrollmentManager experimentEnrollmentManager; - - private Account account; - - private static final UUID ACCOUNT_UUID = UUID.randomUUID(); - private static final String UUID_EXPERIMENT_NAME = "uuid_test"; - - private static final String ENROLLED_164 = "+12025551212"; - private static final String EXCLUDED_164 = "+18005551212"; - private static final String E164_EXPERIMENT_NAME = "e164_test"; - - @BeforeEach - void setUp() { - final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - - experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager); - - experimentEnrollmentConfiguration = mock(DynamicExperimentEnrollmentConfiguration.class); - preRegistrationExperimentEnrollmentConfiguration = mock( - DynamicPreRegistrationExperimentEnrollmentConfiguration.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - when(dynamicConfiguration.getExperimentEnrollmentConfiguration(UUID_EXPERIMENT_NAME)) - .thenReturn(Optional.of(experimentEnrollmentConfiguration)); - when(dynamicConfiguration.getPreRegistrationEnrollmentConfiguration(E164_EXPERIMENT_NAME)) - .thenReturn(Optional.of(preRegistrationExperimentEnrollmentConfiguration)); - - account = mock(Account.class); - when(account.getUuid()).thenReturn(ACCOUNT_UUID); - } - - @Test - void testIsEnrolled_UuidExperiment() { - assertFalse(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); - assertFalse( - experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME + "-unrelated-experiment")); - - when(experimentEnrollmentConfiguration.getEnrolledUuids()).thenReturn(Set.of(ACCOUNT_UUID)); - assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); - - when(experimentEnrollmentConfiguration.getEnrolledUuids()).thenReturn(Collections.emptySet()); - when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(0); - - assertFalse(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); - - when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(100); - assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME)); - } - - @ParameterizedTest - @MethodSource - void testIsEnrolled_PreRegistrationExperiment(final String e164, final String experimentName, - final Set enrolledE164s, final Set excludedE164s, final Set includedCountryCodes, - final Set excludedCountryCodes, - final int enrollmentPercentage, - final boolean expectEnrolled, final String message) { - - when(preRegistrationExperimentEnrollmentConfiguration.getEnrolledE164s()).thenReturn(enrolledE164s); - when(preRegistrationExperimentEnrollmentConfiguration.getExcludedE164s()).thenReturn(excludedE164s); - when(preRegistrationExperimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(enrollmentPercentage); - when(preRegistrationExperimentEnrollmentConfiguration.getIncludedCountryCodes()).thenReturn(includedCountryCodes); - when(preRegistrationExperimentEnrollmentConfiguration.getExcludedCountryCodes()).thenReturn(excludedCountryCodes); - - assertEquals(expectEnrolled, experimentEnrollmentManager.isEnrolled(e164, experimentName), message); - } - - @SuppressWarnings("unused") - static Stream testIsEnrolled_PreRegistrationExperiment() { - return Stream.of( - Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(), - Collections.emptySet(), Collections.emptySet(), 0, false, "default configuration expects no enrollment"), - Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME + "-unrelated-experiment", Collections.emptySet(), - Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), 0, false, - "unknown experiment expects no enrollment"), - Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Set.of(ENROLLED_164), Set.of(EXCLUDED_164), - Collections.emptySet(), Collections.emptySet(), 0, true, "explicitly enrolled E164 overrides 0% rollout"), - Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Set.of(ENROLLED_164), Set.of(EXCLUDED_164), - Collections.emptySet(), Set.of("1"), 0, true, "explicitly enrolled E164 overrides excluded country code"), - Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(), Set.of("1"), - Collections.emptySet(), 0, true, "included country code overrides 0% rollout"), - Arguments.of(EXCLUDED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Set.of(EXCLUDED_164), Set.of("1"), - Collections.emptySet(), 100, false, "excluded E164 overrides 100% rollout"), - Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(), - Collections.emptySet(), Set.of("1"), 100, false, "excluded country code overrides 100% rollout"), - Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(), - Collections.emptySet(), Collections.emptySet(), 100, true, "enrollment expected for 100% rollout") - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentTest.java deleted file mode 100644 index 667f4eea2..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentTest.java +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.experiment; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.verify; - -import io.micrometer.core.instrument.Timer; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class ExperimentTest { - - private Timer matchTimer; - private Timer errorTimer; - - private Experiment experiment; - - @BeforeEach - void setUp() { - matchTimer = mock(Timer.class); - errorTimer = mock(Timer.class); - - experiment = new Experiment("test", matchTimer, errorTimer, mock(Timer.class), mock(Timer.class), - mock(Timer.class)); - } - - @Test - void compareFutureResult() { - experiment.compareFutureResult(12, CompletableFuture.completedFuture(12)); - verify(matchTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS)); - } - - @Test - void compareFutureResultError() { - experiment.compareFutureResult(12, CompletableFuture.failedFuture(new RuntimeException("OH NO"))); - verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS)); - } - - @Test - void compareSupplierResultMatch() { - experiment.compareSupplierResult(12, () -> 12); - verify(matchTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS)); - } - - @Test - void compareSupplierResultError() { - experiment.compareSupplierResult(12, () -> { - throw new RuntimeException("OH NO"); - }); - verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS)); - } - - @Test - void compareSupplierResultAsyncMatch() throws InterruptedException { - final ExecutorService experimentExecutor = Executors.newSingleThreadExecutor(); - - experiment.compareSupplierResultAsync(12, () -> 12, experimentExecutor); - experimentExecutor.shutdown(); - experimentExecutor.awaitTermination(1, TimeUnit.SECONDS); - - verify(matchTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS)); - } - - @Test - void compareSupplierResultAsyncError() throws InterruptedException { - final ExecutorService experimentExecutor = Executors.newSingleThreadExecutor(); - - experiment.compareSupplierResultAsync(12, () -> { - throw new RuntimeException("OH NO"); - }, experimentExecutor); - experimentExecutor.shutdown(); - experimentExecutor.awaitTermination(1, TimeUnit.SECONDS); - - verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS)); - } - - @Test - void compareSupplierResultAsyncRejection() { - final ExecutorService executorService = mock(ExecutorService.class); - doThrow(new RejectedExecutionException()).when(executorService).execute(any(Runnable.class)); - - experiment.compareSupplierResultAsync(12, () -> 12, executorService); - verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS)); - } - - @ParameterizedTest - @MethodSource - public void testRecordResult(final Object expected, final Object actual, final Experiment experiment, - final Timer expectedTimer) { - reset(expectedTimer); - - final long durationNanos = 123; - - experiment.recordResult(expected, actual, durationNanos); - verify(expectedTimer).record(durationNanos, TimeUnit.NANOSECONDS); - } - - @SuppressWarnings("unused") - private static Stream testRecordResult() { - // Hack: parameters are set before the @Before method gets called - final Timer matchTimer = mock(Timer.class); - final Timer errorTimer = mock(Timer.class); - final Timer bothPresentMismatchTimer = mock(Timer.class); - final Timer controlNullMismatchTimer = mock(Timer.class); - final Timer experimentNullMismatchTimer = mock(Timer.class); - - final Experiment experiment = new Experiment("test", matchTimer, errorTimer, bothPresentMismatchTimer, - controlNullMismatchTimer, experimentNullMismatchTimer); - - return Stream.of( - Arguments.of(12, 12, experiment, matchTimer), - Arguments.of(null, 12, experiment, controlNullMismatchTimer), - Arguments.of(12, null, experiment, experimentNullMismatchTimer), - Arguments.of(12, 17, experiment, bothPresentMismatchTimer), - Arguments.of(Optional.of(12), Optional.of(12), experiment, matchTimer), - Arguments.of(Optional.empty(), Optional.of(12), experiment, controlNullMismatchTimer), - Arguments.of(Optional.of(12), Optional.empty(), experiment, experimentNullMismatchTimer), - Arguments.of(Optional.of(12), Optional.of(17), experiment, bothPresentMismatchTimer) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java deleted file mode 100644 index 164568a07..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.filters; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.common.net.HttpHeaders; -import com.vdurmont.semver4j.Semver; -import java.io.IOException; -import java.util.EnumMap; -import java.util.Set; -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; - -class RemoteDeprecationFilterTest { - - @Test - void testEmptyMap() throws IOException, ServletException { - // We're happy as long as there's no exception - final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - final DynamicRemoteDeprecationConfiguration emptyConfiguration = new DynamicRemoteDeprecationConfiguration(); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(emptyConfiguration); - - final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager); - - final HttpServletRequest servletRequest = mock(HttpServletRequest.class); - final HttpServletResponse servletResponse = mock(HttpServletResponse.class); - final FilterChain filterChain = mock(FilterChain.class); - - when(servletRequest.getHeader("UserAgent")).thenReturn("Signal-Android/4.68.3"); - - filter.doFilter(servletRequest, servletResponse, filterChain); - - verify(filterChain).doFilter(servletRequest, servletResponse); - verify(servletResponse, never()).sendError(anyInt()); - } - - @ParameterizedTest - @CsvSource(delimiter = '|', value = - {"Unrecognized UA | false", - "Signal-Android/4.68.3 | false", - "Signal-iOS/3.9.0 | false", - "Signal-Desktop/1.2.3 | false", - "Signal-Android/0.68.3 | true", - "Signal-iOS/0.9.0 | true", - "Signal-Desktop/0.2.3 | true", - "Signal-Desktop/8.0.0-beta.2 | true", - "Signal-Desktop/8.0.0-beta.1 | false", - "Signal-iOS/8.0.0-beta.2 | false"}) - void testFilter(final String userAgent, final boolean expectDeprecation) throws IOException, ServletException { - final EnumMap minimumVersionsByPlatform = new EnumMap<>(ClientPlatform.class); - minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.0.0")); - minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.0.0")); - minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.0.0")); - - final EnumMap versionsPendingDeprecationByPlatform = new EnumMap<>(ClientPlatform.class); - minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver("1.1.0")); - minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver("1.1.0")); - minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver("1.1.0")); - - final EnumMap> blockedVersionsByPlatform = new EnumMap<>(ClientPlatform.class); - blockedVersionsByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.2"))); - - final EnumMap> versionsPendingBlockByPlatform = new EnumMap<>(ClientPlatform.class); - versionsPendingBlockByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver("8.0.0-beta.3"))); - - final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = new DynamicRemoteDeprecationConfiguration(); - remoteDeprecationConfiguration.setMinimumVersions(minimumVersionsByPlatform); - remoteDeprecationConfiguration.setVersionsPendingDeprecation(versionsPendingDeprecationByPlatform); - remoteDeprecationConfiguration.setBlockedVersions(blockedVersionsByPlatform); - remoteDeprecationConfiguration.setVersionsPendingBlock(versionsPendingBlockByPlatform); - remoteDeprecationConfiguration.setUnrecognizedUserAgentAllowed(true); - - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(remoteDeprecationConfiguration); - - final HttpServletRequest servletRequest = mock(HttpServletRequest.class); - final HttpServletResponse servletResponse = mock(HttpServletResponse.class); - final FilterChain filterChain = mock(FilterChain.class); - - when(servletRequest.getHeader(HttpHeaders.USER_AGENT)).thenReturn(userAgent); - - final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager); - filter.doFilter(servletRequest, servletResponse, filterChain); - - if (expectDeprecation) { - verify(filterChain, never()).doFilter(any(), any()); - verify(servletResponse).sendError(499); - } else { - verify(filterChain).doFilter(servletRequest, servletResponse); - verify(servletResponse, never()).sendError(anyInt()); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilterTest.java deleted file mode 100644 index 32038cc8d..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilterTest.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.filters; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import javax.ws.rs.container.ContainerRequestContext; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.metrics.TrafficSource; - -class RequestStatisticsFilterTest { - - @Test - void testFilter() throws Exception { - - final RequestStatisticsFilter requestStatisticsFilter = new RequestStatisticsFilter(TrafficSource.WEBSOCKET); - - final ContainerRequestContext requestContext = mock(ContainerRequestContext.class); - - when(requestContext.getLength()).thenReturn(-1); - when(requestContext.getLength()).thenReturn(Integer.MAX_VALUE); - when(requestContext.getLength()).thenThrow(RuntimeException.class); - - requestStatisticsFilter.filter(requestContext); - requestStatisticsFilter.filter(requestContext); - requestStatisticsFilter.filter(requestContext); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilterTest.java deleted file mode 100644 index 100931eac..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilterTest.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.filters; - -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import javax.ws.rs.container.ContainerRequestContext; -import javax.ws.rs.container.ContainerResponseContext; -import javax.ws.rs.core.MultivaluedMap; -import org.glassfish.jersey.message.internal.HeaderUtils; -import org.junit.jupiter.api.Test; - -class TimestampResponseFilterTest { - - @Test - void testFilter() { - final ContainerRequestContext requestContext = mock(ContainerRequestContext.class); - final ContainerResponseContext responseContext = mock(ContainerResponseContext.class); - - final MultivaluedMap headers = HeaderUtils.createOutbound(); - - when(responseContext.getHeaders()).thenReturn(headers); - - new TimestampResponseFilter().filter(requestContext, responseContext); - - assertTrue(headers.containsKey(org.whispersystems.textsecuregcm.util.HeaderUtils.TIMESTAMP_HEADER)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java deleted file mode 100644 index 74e052c04..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.whispersystems.textsecuregcm.limits; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener; -import org.whispersystems.textsecuregcm.captcha.AssessmentResult; -import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.storage.Account; - -class RateLimitChallengeManagerTest { - - private PushChallengeManager pushChallengeManager; - private CaptchaChecker captchaChecker; - private DynamicRateLimiters rateLimiters; - private RateLimitChallengeListener rateLimitChallengeListener; - - private RateLimitChallengeManager rateLimitChallengeManager; - - @BeforeEach - void setUp() { - pushChallengeManager = mock(PushChallengeManager.class); - captchaChecker = mock(CaptchaChecker.class); - rateLimiters = mock(DynamicRateLimiters.class); - rateLimitChallengeListener = mock(RateLimitChallengeListener.class); - - rateLimitChallengeManager = new RateLimitChallengeManager( - pushChallengeManager, - captchaChecker, - rateLimiters); - - rateLimitChallengeManager.addListener(rateLimitChallengeListener); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void answerPushChallenge(final boolean successfulChallenge) throws RateLimitExceededException { - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - - when(pushChallengeManager.answerChallenge(eq(account), any())).thenReturn(successfulChallenge); - - when(rateLimiters.getPushChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class)); - when(rateLimiters.getPushChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class)); - when(rateLimiters.getRateLimitResetLimiter()).thenReturn(mock(RateLimiter.class)); - - rateLimitChallengeManager.answerPushChallenge(account, "challenge"); - - if (successfulChallenge) { - verify(rateLimitChallengeListener).handleRateLimitChallengeAnswered(account); - } else { - verifyNoInteractions(rateLimitChallengeListener); - } - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void answerRecaptchaChallenge(final boolean successfulChallenge) throws RateLimitExceededException, IOException { - final Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - - when(captchaChecker.verify(any(), any())) - .thenReturn(successfulChallenge - ? new AssessmentResult(true, "") - : AssessmentResult.invalid()); - - when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class)); - when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class)); - when(rateLimiters.getRateLimitResetLimiter()).thenReturn(mock(RateLimiter.class)); - - rateLimitChallengeManager.answerRecaptchaChallenge(account, "captcha", "10.0.0.1", "Test User-Agent"); - - if (successfulChallenge) { - verify(rateLimitChallengeListener).handleRateLimitChallengeAnswered(account); - } else { - verifyNoInteractions(rateLimitChallengeListener); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManagerTest.java deleted file mode 100644 index df893be0f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManagerTest.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.vdurmont.semver4j.Semver; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitChallengeConfiguration; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; - -class RateLimitChallengeOptionManagerTest { - - private DynamicRateLimitChallengeConfiguration rateLimitChallengeConfiguration; - private DynamicRateLimiters rateLimiters; - - private RateLimitChallengeOptionManager rateLimitChallengeOptionManager; - - @BeforeEach - void setUp() { - rateLimiters = mock(DynamicRateLimiters.class); - - final DynamicConfigurationManager dynamicConfigurationManager = - mock(DynamicConfigurationManager.class); - - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - rateLimitChallengeConfiguration = mock(DynamicRateLimitChallengeConfiguration.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - when(dynamicConfiguration.getRateLimitChallengeConfiguration()).thenReturn(rateLimitChallengeConfiguration); - - rateLimitChallengeOptionManager = new RateLimitChallengeOptionManager(rateLimiters, dynamicConfigurationManager); - } - - @ParameterizedTest - @MethodSource - void isClientBelowMinimumVersion(final String userAgent, final boolean expectBelowMinimumVersion) { - when(rateLimitChallengeConfiguration.getMinimumSupportedVersion(any())).thenReturn(Optional.empty()); - when(rateLimitChallengeConfiguration.getMinimumSupportedVersion(ClientPlatform.ANDROID)) - .thenReturn(Optional.of(new Semver("5.6.0"))); - when(rateLimitChallengeConfiguration.getMinimumSupportedVersion(ClientPlatform.DESKTOP)) - .thenReturn(Optional.of(new Semver("5.0.0-beta.2"))); - - assertEquals(expectBelowMinimumVersion, rateLimitChallengeOptionManager.isClientBelowMinimumVersion(userAgent)); - } - - private static Stream isClientBelowMinimumVersion() { - return Stream.of( - Arguments.of("Signal-Android/5.1.2 Android/30", true), - Arguments.of("Signal-Android/5.6.0 Android/30", false), - Arguments.of("Signal-Android/5.11.1 Android/30", false), - Arguments.of("Signal-Desktop/5.0.0-beta.3 macOS/11", false), - Arguments.of("Signal-Desktop/5.0.0-beta.1 Windows/3.1", true), - Arguments.of("Signal-Desktop/5.2.0 Debian/11", false), - Arguments.of("Signal-iOS/5.1.2 iOS/12.2", true), - Arguments.of("anything-else", false) - ); - } - - @ParameterizedTest - @MethodSource - void getChallengeOptions(final boolean captchaAttemptPermitted, - final boolean captchaSuccessPermitted, - final boolean pushAttemptPermitted, - final boolean pushSuccessPermitted, - final boolean expectCaptcha, - final boolean expectPushChallenge) { - - final RateLimiter recaptchaChallengeAttemptLimiter = mock(RateLimiter.class); - final RateLimiter recaptchaChallengeSuccessLimiter = mock(RateLimiter.class); - final RateLimiter pushChallengeAttemptLimiter = mock(RateLimiter.class); - final RateLimiter pushChallengeSuccessLimiter = mock(RateLimiter.class); - - when(rateLimiters.getRecaptchaChallengeAttemptLimiter()).thenReturn(recaptchaChallengeAttemptLimiter); - when(rateLimiters.getRecaptchaChallengeSuccessLimiter()).thenReturn(recaptchaChallengeSuccessLimiter); - when(rateLimiters.getPushChallengeAttemptLimiter()).thenReturn(pushChallengeAttemptLimiter); - when(rateLimiters.getPushChallengeSuccessLimiter()).thenReturn(pushChallengeSuccessLimiter); - - when(recaptchaChallengeAttemptLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(captchaAttemptPermitted); - when(recaptchaChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(captchaSuccessPermitted); - when(pushChallengeAttemptLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(pushAttemptPermitted); - when(pushChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyInt())).thenReturn(pushSuccessPermitted); - - final int expectedLength = (expectCaptcha ? 1 : 0) + (expectPushChallenge ? 1 : 0); - - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - - final List options = rateLimitChallengeOptionManager.getChallengeOptions(account); - assertEquals(expectedLength, options.size()); - - if (expectCaptcha) { - assertTrue(options.contains(RateLimitChallengeOptionManager.OPTION_RECAPTCHA)); - } - - if (expectPushChallenge) { - assertTrue(options.contains(RateLimitChallengeOptionManager.OPTION_PUSH_CHALLENGE)); - } - } - - private static Stream getChallengeOptions() { - return Stream.of( - Arguments.of(false, false, false, false, false, false), - Arguments.of(false, false, false, true, false, false), - Arguments.of(false, false, true, false, false, false), - Arguments.of(false, false, true, true, false, true), - Arguments.of(false, true, false, false, false, false), - Arguments.of(false, true, false, true, false, false), - Arguments.of(false, true, true, false, false, false), - Arguments.of(false, true, true, true, false, true), - Arguments.of(true, false, false, false, false, false), - Arguments.of(true, false, false, true, false, false), - Arguments.of(true, false, true, false, false, false), - Arguments.of(true, false, true, true, false, true), - Arguments.of(true, true, false, false, true, false), - Arguments.of(true, true, false, true, true, false), - Arguments.of(true, true, true, false, true, false), - Arguments.of(true, true, true, true, true, true) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIpTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIpTest.java deleted file mode 100644 index 98dc855c9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIpTest.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.limits; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.google.common.net.HttpHeaders; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.time.Duration; -import java.util.Optional; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -public class RateLimitedByIpTest { - - private static final String IP = "70.130.130.200"; - - private static final String VALID_X_FORWARDED_FOR = "1.1.1.1," + IP; - - private static final String INVALID_X_FORWARDED_FOR = "1.1.1.1,"; - - private static final Duration RETRY_AFTER = Duration.ofSeconds(100); - - private static final Duration RETRY_AFTER_INVALID_HEADER = RateLimitByIpFilter.INVALID_HEADER_EXCEPTION - .getRetryDuration() - .orElseThrow(); - - - @Path("/test") - public static class Controller { - @GET - @Path("/strict") - @RateLimitedByIp(RateLimiters.Handle.BACKUP_AUTH_CHECK) - public Response strict() { - return Response.ok().build(); - } - - @GET - @Path("/loose") - @RateLimitedByIp(value = RateLimiters.Handle.BACKUP_AUTH_CHECK, failOnUnresolvedIp = false) - public Response loose() { - return Response.ok().build(); - } - } - - private static final RateLimiter RATE_LIMITER = Mockito.mock(RateLimiter.class); - - private static final RateLimiters RATE_LIMITERS = MockUtils.buildMock(RateLimiters.class, rl -> - Mockito.when(rl.byHandle(Mockito.eq(RateLimiters.Handle.BACKUP_AUTH_CHECK))).thenReturn(Optional.of(RATE_LIMITER))); - - private static final ResourceExtension RESOURCES = ResourceExtension.builder() - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new Controller()) - .addProvider(new RateLimitByIpFilter(RATE_LIMITERS)) - .build(); - - @Test - public void testRateLimits() throws Exception { - Mockito.doNothing().when(RATE_LIMITER).validate(Mockito.eq(IP)); - validateSuccess("/test/strict", VALID_X_FORWARDED_FOR); - Mockito.doThrow(new RateLimitExceededException(RETRY_AFTER, true)).when(RATE_LIMITER).validate(Mockito.eq(IP)); - validateFailure("/test/strict", VALID_X_FORWARDED_FOR, RETRY_AFTER); - Mockito.doNothing().when(RATE_LIMITER).validate(Mockito.eq(IP)); - validateSuccess("/test/strict", VALID_X_FORWARDED_FOR); - Mockito.doThrow(new RateLimitExceededException(RETRY_AFTER, true)).when(RATE_LIMITER).validate(Mockito.eq(IP)); - validateFailure("/test/strict", VALID_X_FORWARDED_FOR, RETRY_AFTER); - } - - @Test - public void testInvalidHeader() throws Exception { - Mockito.doNothing().when(RATE_LIMITER).validate(Mockito.eq(IP)); - validateSuccess("/test/strict", VALID_X_FORWARDED_FOR); - validateFailure("/test/strict", INVALID_X_FORWARDED_FOR, RETRY_AFTER_INVALID_HEADER); - validateFailure("/test/strict", "", RETRY_AFTER_INVALID_HEADER); - - validateSuccess("/test/loose", VALID_X_FORWARDED_FOR); - validateSuccess("/test/loose", INVALID_X_FORWARDED_FOR); - validateSuccess("/test/loose", ""); - - // also checking that even if rate limiter is failing -- it doesn't matter in the case of invalid IP - Mockito.doThrow(new RateLimitExceededException(RETRY_AFTER, true)).when(RATE_LIMITER).validate(Mockito.anyString()); - validateFailure("/test/loose", VALID_X_FORWARDED_FOR, RETRY_AFTER); - validateSuccess("/test/loose", INVALID_X_FORWARDED_FOR); - validateSuccess("/test/loose", ""); - } - - private static void validateSuccess(final String path, final String xff) { - final Response response = RESOURCES.getJerseyTest() - .target(path) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, xff) - .get(); - - assertEquals(200, response.getStatus()); - } - - private static void validateFailure(final String path, final String xff, final Duration expectedRetryAfter) { - final Response response = RESOURCES.getJerseyTest() - .target(path) - .request() - .header(HttpHeaders.X_FORWARDED_FOR, xff) - .get(); - - assertEquals(413, response.getStatus()); - assertEquals("" + expectedRetryAfter.getSeconds(), response.getHeaderString(HttpHeaders.RETRY_AFTER)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MessageMetricsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MessageMetricsTest.java deleted file mode 100644 index 97fc3db3d..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MessageMetricsTest.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.Meter; -import io.micrometer.core.instrument.Metrics; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity; -import org.whispersystems.textsecuregcm.storage.Account; - -class MessageMetricsTest { - - private final Account account = mock(Account.class); - private final UUID aci = UUID.fromString("11111111-1111-1111-1111-111111111111"); - private final UUID pni = UUID.fromString("22222222-2222-2222-2222-222222222222"); - private final UUID otherUuid = UUID.fromString("99999999-9999-9999-9999-999999999999"); - private SimpleMeterRegistry simpleMeterRegistry; - - @BeforeEach - void setup() { - when(account.getUuid()).thenReturn(aci); - when(account.getPhoneNumberIdentifier()).thenReturn(pni); - Metrics.globalRegistry.clear(); - simpleMeterRegistry = new SimpleMeterRegistry(); - Metrics.globalRegistry.add(simpleMeterRegistry); - } - - @AfterEach - void teardown() { - Metrics.globalRegistry.remove(simpleMeterRegistry); - Metrics.globalRegistry.clear(); - } - - @Test - void measureAccountOutgoingMessageUuidMismatches() { - - final OutgoingMessageEntity outgoingMessageToAci = createOutgoingMessageEntity(aci); - MessageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageToAci); - - Optional counter = findCounter(simpleMeterRegistry); - - assertTrue(counter.isEmpty()); - - final OutgoingMessageEntity outgoingMessageToPni = createOutgoingMessageEntity(pni); - MessageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageToPni); - counter = findCounter(simpleMeterRegistry); - - assertTrue(counter.isEmpty()); - - final OutgoingMessageEntity outgoingMessageToOtherUuid = createOutgoingMessageEntity(otherUuid); - MessageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageToOtherUuid); - counter = findCounter(simpleMeterRegistry); - - assertEquals(1.0, counter.map(Counter::count).orElse(0.0)); - } - - private OutgoingMessageEntity createOutgoingMessageEntity(UUID destinationUuid) { - return new OutgoingMessageEntity(UUID.randomUUID(), 1, 1L, null, 1, destinationUuid, null, new byte[]{}, 1, true, false); - } - - @Test - void measureAccountEnvelopeUuidMismatches() { - final MessageProtos.Envelope envelopeToAci = createEnvelope(aci); - MessageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToAci); - - Optional counter = findCounter(simpleMeterRegistry); - - assertTrue(counter.isEmpty()); - - final MessageProtos.Envelope envelopeToPni = createEnvelope(pni); - MessageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToPni); - counter = findCounter(simpleMeterRegistry); - - assertTrue(counter.isEmpty()); - - final MessageProtos.Envelope envelopeToOtherUuid = createEnvelope(otherUuid); - MessageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToOtherUuid); - counter = findCounter(simpleMeterRegistry); - - assertEquals(1.0, counter.map(Counter::count).orElse(0.0)); - - final MessageProtos.Envelope envelopeToNull = createEnvelope(null); - MessageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToNull); - counter = findCounter(simpleMeterRegistry); - - assertEquals(1.0, counter.map(Counter::count).orElse(0.0)); - } - - private MessageProtos.Envelope createEnvelope(UUID destinationUuid) { - final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder(); - - if (destinationUuid != null) { - builder.setDestinationUuid(destinationUuid.toString()); - } - - return builder.build(); - } - - private Optional findCounter(SimpleMeterRegistry meterRegistry) { - final Optional maybeMeter = meterRegistry.getMeters().stream().findFirst(); - return maybeMeter.map(meter -> meter instanceof Counter ? (Counter) meter : null); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java deleted file mode 100644 index b7608e1de..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java +++ /dev/null @@ -1,355 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.net.HttpHeaders; -import com.google.protobuf.InvalidProtocolBufferException; -import com.vdurmont.semver4j.Semver; -import io.dropwizard.jersey.DropwizardResourceConfig; -import io.dropwizard.jersey.jackson.JacksonMessageBodyProvider; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tag; -import java.nio.ByteBuffer; -import java.security.Principal; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Stream; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import org.eclipse.jetty.websocket.api.RemoteEndpoint; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.UpgradeRequest; -import org.glassfish.jersey.server.ApplicationHandler; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.ContainerResponse; -import org.glassfish.jersey.server.ExtendedUriInfo; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.server.monitoring.RequestEvent; -import org.glassfish.jersey.uri.UriTemplate; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; -import org.whispersystems.textsecuregcm.util.ua.UserAgent; -import org.whispersystems.websocket.WebSocketResourceProvider; -import org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider; -import org.whispersystems.websocket.logging.WebsocketRequestLog; -import org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory; -import org.whispersystems.websocket.messages.protobuf.SubProtocol; -import org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider; - -class MetricsRequestEventListenerTest { - - private MeterRegistry meterRegistry; - private Counter counter; - private MetricsRequestEventListener listener; - - private static final TrafficSource TRAFFIC_SOURCE = TrafficSource.HTTP; - - @BeforeEach - void setup() { - meterRegistry = mock(MeterRegistry.class); - counter = mock(Counter.class); - listener = new MetricsRequestEventListener(TRAFFIC_SOURCE, meterRegistry); - } - - @Test - @SuppressWarnings("unchecked") - void testOnEvent() { - final String path = "/test"; - final int statusCode = 200; - - final ExtendedUriInfo uriInfo = mock(ExtendedUriInfo.class); - when(uriInfo.getMatchedTemplates()).thenReturn(Collections.singletonList(new UriTemplate(path))); - - final ContainerRequest request = mock(ContainerRequest.class); - when(request.getRequestHeader(HttpHeaders.USER_AGENT)).thenReturn(Collections.singletonList("Signal-Android 4.53.7 (Android 8.1)")); - - final ContainerResponse response = mock(ContainerResponse.class); - when(response.getStatus()).thenReturn(statusCode); - - final RequestEvent event = mock(RequestEvent.class); - when(event.getType()).thenReturn(RequestEvent.Type.FINISHED); - when(event.getUriInfo()).thenReturn(uriInfo); - when(event.getContainerRequest()).thenReturn(request); - when(event.getContainerResponse()).thenReturn(response); - - final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); - when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(counter); - - listener.onEvent(event); - - verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture()); - - final Iterable tagIterable = tagCaptor.getValue(); - final Set tags = new HashSet<>(); - - for (final Tag tag : tagIterable) { - tags.add(tag); - } - - // TODO Restore this when we return to detailed metrics and restore the version tag - // assertEquals(5, tags.size()); - assertEquals(4, tags.size()); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.PATH_TAG, path))); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(statusCode)))); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()))); - assertTrue(tags.contains(Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android"))); - // assertTrue(tags.contains(Tag.of(UserAgentTagUtil.VERSION_TAG, "4.53.7"))); - } - - @Test - void testActualRouteMessageSuccess() throws InvalidProtocolBufferException { - MetricsApplicationEventListener applicationEventListener = mock(MetricsApplicationEventListener.class); - when(applicationEventListener.onRequest(any())).thenReturn(listener); - - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(applicationEventListener); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, requestLog, new TestPrincipal("foo"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class ); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - when(request.getHeader(HttpHeaders.USER_AGENT)).thenReturn("Signal-Android 4.53.7 (Android 8.1)"); - when(request.getHeaders()).thenReturn(Map.of(HttpHeaders.USER_AGENT, List.of("Signal-Android 4.53.7 (Android 8.1)"))); - - final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); - when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(counter); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/hello", new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture()); - - final Iterable tagIterable = tagCaptor.getValue(); - final Set tags = new HashSet<>(); - - for (final Tag tag : tagIterable) { - tags.add(tag); - } - - // TODO Restore this when we return to detailed metrics and restore the version tag - // assertEquals(5, tags.size()); - assertEquals(4, tags.size()); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.PATH_TAG, "/v1/test/hello"))); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(200)))); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()))); - assertTrue(tags.contains(Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android"))); - // assertTrue(tags.contains(Tag.of(UserAgentTagUtil.VERSION_TAG, "4.53.7"))); - } - - @Test - void testActualRouteMessageSuccessNoUserAgent() throws InvalidProtocolBufferException { - MetricsApplicationEventListener applicationEventListener = mock(MetricsApplicationEventListener.class); - when(applicationEventListener.onRequest(any())).thenReturn(listener); - - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(applicationEventListener); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, requestLog, new TestPrincipal("foo"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class ); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); - when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(counter); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/hello", new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getStatus()).isEqualTo(200); - - verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture()); - - final Iterable tagIterable = tagCaptor.getValue(); - final Set tags = new HashSet<>(); - - for (final Tag tag : tagIterable) { - tags.add(tag); - } - - // TODO Restore this when we return to detailed metrics and restore the version tag - // assertEquals(5, tags.size()); - assertEquals(4, tags.size()); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.PATH_TAG, "/v1/test/hello"))); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(200)))); - assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()))); - // assertTrue(tags.containsAll(UserAgentTagUtil.UNRECOGNIZED_TAGS)); - assertTrue(tags.contains(Tag.of(UserAgentTagUtil.PLATFORM_TAG, "unrecognized"))); - } - - @ParameterizedTest - @MethodSource - void testRecordDesktopOperatingSystem(final UserAgent userAgent, final String expectedOperatingSystem) { - when(meterRegistry.counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME), (String)any())).thenReturn(counter); - listener.recordDesktopOperatingSystem(userAgent); - - if (expectedOperatingSystem != null) { - final ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(String.class); - verify(meterRegistry).counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME), tagCaptor.capture()); - - assertEquals(List.of(MetricsRequestEventListener.OS_TAG, expectedOperatingSystem), tagCaptor.getAllValues()); - } else { - verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME)); - verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME), (String)any()); - } - } - - private static Stream testRecordDesktopOperatingSystem() { - return Stream.of( - Arguments.of( new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Linux"), "linux" ), - Arguments.of( new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "macOS"), "macos" ), - Arguments.of( new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Windows"), "windows" ), - Arguments.of( new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3")), null ), - Arguments.of( new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25"), null ), - Arguments.of( new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)"), null ) - ); - } - - @ParameterizedTest - @MethodSource - void testRecordAndroidSdkVersion(final UserAgent userAgent, final String expectedSdkVersion) { - when(meterRegistry.counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME), (String)any())).thenReturn(counter); - listener.recordAndroidSdkVersion(userAgent); - - if (expectedSdkVersion != null) { - final ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(String.class); - verify(meterRegistry).counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME), tagCaptor.capture()); - - assertEquals(List.of(MetricsRequestEventListener.SDK_TAG, expectedSdkVersion), tagCaptor.getAllValues()); - } else { - verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME)); - verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME), (String)any()); - } - } - - private static Stream testRecordAndroidSdkVersion() { - return Stream.of( - Arguments.of( new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/1"), null ), - Arguments.of( new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25"), "25" ), - Arguments.of( new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/700000"), null ), - Arguments.of( new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/"), null ), - Arguments.of( new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), null), null ), - Arguments.of( new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Linux"), null ), - Arguments.of( new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)"), null ) - ); - } - - @ParameterizedTest - @MethodSource - void testRecordIosVersion(final UserAgent userAgent, final String expectedIosVersion) { - when(meterRegistry.counter(eq(MetricsRequestEventListener.IOS_REQUEST_COUNTER_NAME), (String)any())).thenReturn(counter); - listener.recordIosVersion(userAgent); - - if (expectedIosVersion != null) { - final ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(String.class); - verify(meterRegistry).counter(eq(MetricsRequestEventListener.IOS_REQUEST_COUNTER_NAME), tagCaptor.capture()); - - assertEquals(List.of(MetricsRequestEventListener.OS_TAG, expectedIosVersion), tagCaptor.getAllValues()); - } else { - verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.IOS_REQUEST_COUNTER_NAME)); - verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.IOS_REQUEST_COUNTER_NAME), (String)any()); - } - } - - private static Stream testRecordIosVersion() { - return Stream.of( - Arguments.of( new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "iOS/14.2"), "14.2" ), - Arguments.of( new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)"), "12.2" ), - Arguments.of( new UserAgent(ClientPlatform.IOS, new Semver("3.9.0")), null ), - Arguments.of( new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "iOS/bogus"), null ), - Arguments.of( new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS bogus; Scale/3.00)"), null ), - Arguments.of( new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25"), null ), - Arguments.of( new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Linux"), null ) - ); - } - - private static SubProtocol.WebSocketResponseMessage getResponse(ArgumentCaptor responseCaptor) throws InvalidProtocolBufferException { - return SubProtocol.WebSocketMessage.parseFrom(responseCaptor.getValue().array()).getResponse(); - } - - public static class TestPrincipal implements Principal { - - private final String name; - - private TestPrincipal(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - } - - @Path("/v1/test") - public static class TestResource { - - @GET - @Path("/hello") - public String testGetHello() { - return "Hello!"; - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsUtilTest.java deleted file mode 100644 index e102d2d92..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsUtilTest.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.jupiter.api.Test; - - -class MetricsUtilTest { - - @Test - void name() { - - assertEquals("chat.MetricsUtilTest.metric", MetricsUtil.name(MetricsUtilTest.class, "metric")); - assertEquals("chat.MetricsUtilTest.namespace.metric", - MetricsUtil.name(MetricsUtilTest.class, "namespace", "metric")); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/OperatingSystemMemoryGaugeTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/OperatingSystemMemoryGaugeTest.java deleted file mode 100644 index 78e62e749..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/OperatingSystemMemoryGaugeTest.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class OperatingSystemMemoryGaugeTest { - - private static final String MEMINFO = - """ - MemTotal: 16052208 kB - MemFree: 4568468 kB - MemAvailable: 7702848 kB - Buffers: 636372 kB - Cached: 5019116 kB - SwapCached: 6692 kB - Active: 7746436 kB - Inactive: 2729876 kB - Active(anon): 5580980 kB - Inactive(anon): 1648108 kB - Active(file): 2165456 kB - Inactive(file): 1081768 kB - Unevictable: 443948 kB - Mlocked: 4924 kB - SwapTotal: 1003516 kB - SwapFree: 935932 kB - Dirty: 28308 kB - Writeback: 0 kB - AnonPages: 5258396 kB - Mapped: 1530740 kB - Shmem: 2419340 kB - KReclaimable: 229392 kB - Slab: 408156 kB - SReclaimable: 229392 kB - SUnreclaim: 178764 kB - KernelStack: 17360 kB - PageTables: 50436 kB - NFS_Unstable: 0 kB - Bounce: 0 kB - WritebackTmp: 0 kB - CommitLimit: 9029620 kB - Committed_AS: 16681884 kB - VmallocTotal: 34359738367 kB - VmallocUsed: 41944 kB - VmallocChunk: 0 kB - Percpu: 4240 kB - HardwareCorrupted: 0 kB - AnonHugePages: 0 kB - ShmemHugePages: 0 kB - ShmemPmdMapped: 0 kB - FileHugePages: 0 kB - FilePmdMapped: 0 kB - CmaTotal: 0 kB - CmaFree: 0 kB - HugePages_Total: 0 - HugePages_Free: 7 - HugePages_Rsvd: 0 - HugePages_Surp: 0 - Hugepagesize: 2048 kB - Hugetlb: 0 kB - DirectMap4k: 481804 kB - DirectMap2M: 14901248 kB - DirectMap1G: 2097152 kB - """; - - @ParameterizedTest - @MethodSource - void testGetValue(final String metricName, final long expectedValue) { - assertEquals(expectedValue, new OperatingSystemMemoryGauge(metricName).getValue(MEMINFO.lines())); - } - - @SuppressWarnings("unused") - private static Stream testGetValue() { - return Stream.of( - Arguments.of("MemTotal", 16052208L), - Arguments.of("Active(anon)", 5580980L), - Arguments.of("Committed_AS", 16681884L), - Arguments.of("HugePages_Free", 7L), - Arguments.of("NonsenseMetric", 0L) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtilTest.java deleted file mode 100644 index 2ded09012..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtilTest.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.metrics; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.micrometer.core.instrument.Tag; -import java.util.HashSet; -import java.util.List; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class UserAgentTagUtilTest { - - @ParameterizedTest - @MethodSource - public void testGetUserAgentTags(final String userAgent, final List expectedTags) { - assertEquals(new HashSet<>(expectedTags), - new HashSet<>(UserAgentTagUtil.getUserAgentTags(userAgent))); - } - - private static List platformVersionTags(String platform, String version) { - return List.of(Tag.of(UserAgentTagUtil.PLATFORM_TAG, platform), Tag.of(UserAgentTagUtil.VERSION_TAG, version)); - } - - @SuppressWarnings("unused") - private static Stream testGetUserAgentTags() { - return Stream.of( - Arguments.of("This is obviously not a reasonable User-Agent string.", UserAgentTagUtil.UNRECOGNIZED_TAGS), - Arguments.of(null, UserAgentTagUtil.UNRECOGNIZED_TAGS), - Arguments.of("Signal-Android 4.53.7 (Android 8.1)", platformVersionTags("android", "4.53.7")), - Arguments.of("Signal Desktop 1.2.3", platformVersionTags("desktop", "1.2.3")), - Arguments.of("Signal/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", platformVersionTags("ios", "3.9.0")), - Arguments.of("Signal-Android 1.2.3 (Android 8.1)", UserAgentTagUtil.UNRECOGNIZED_TAGS), - Arguments.of("Signal Desktop 3.9.0", platformVersionTags("desktop", "3.9.0")), - Arguments.of("Signal/4.53.7 (iPhone; iOS 12.2; Scale/3.00)", platformVersionTags("ios", "4.53.7")), - Arguments.of("Signal-Android 4.68.3 (Android 9)", platformVersionTags("android", "4.68.3")), - Arguments.of("Signal-Android 1.2.3 (Android 4.3)", UserAgentTagUtil.UNRECOGNIZED_TAGS), - Arguments.of("Signal-Android 4.68.3.0-bobsbootlegclient", UserAgentTagUtil.UNRECOGNIZED_TAGS), - Arguments.of("Signal Desktop 1.22.45-foo-0", UserAgentTagUtil.UNRECOGNIZED_TAGS), - Arguments.of("Signal Desktop 1.34.5-beta.1-fakeclientemporium", UserAgentTagUtil.UNRECOGNIZED_TAGS), - Arguments.of("Signal Desktop 1.32.0-beta.3", UserAgentTagUtil.UNRECOGNIZED_TAGS) - ); - } - - @Test - void testGetUserAgentTagsFlooded() { - for (int i = 0; i < UserAgentTagUtil.MAX_VERSIONS; i++) { - UserAgentTagUtil.getUserAgentTags(String.format("Signal-Android 4.0.%d (Android 8.1)", i)); - } - - assertEquals(UserAgentTagUtil.OVERFLOW_TAGS, - UserAgentTagUtil.getUserAgentTags("Signal-Android 4.1.0 (Android 8.1)")); - - final List tags = UserAgentTagUtil.getUserAgentTags("Signal-Android 4.0.0 (Android 8.1)"); - - assertEquals(2, tags.size()); - assertTrue(tags.contains(Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android"))); - assertTrue(tags.contains(Tag.of(UserAgentTagUtil.VERSION_TAG, "4.0.0"))); - } - - @ParameterizedTest - @MethodSource("argumentsForTestGetPlatformTag") - public void testGetPlatformTag(final String userAgent, final Tag expectedTag) { - assertEquals(expectedTag, UserAgentTagUtil.getPlatformTag(userAgent)); - } - - private static Stream argumentsForTestGetPlatformTag() { - return Stream.of( - Arguments.of("This is obviously not a reasonable User-Agent string.", - Tag.of(UserAgentTagUtil.PLATFORM_TAG, "unrecognized")), - Arguments.of(null, Tag.of(UserAgentTagUtil.PLATFORM_TAG, "unrecognized")), - Arguments.of("Signal-Android 4.53.7 (Android 8.1)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), - Arguments.of("Signal Desktop 1.2.3", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), - Arguments.of("Signal/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "ios")), - Arguments.of("Signal-Android 1.2.3 (Android 8.1)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), - Arguments.of("Signal Desktop 3.9.0", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), - Arguments.of("Signal/4.53.7 (iPhone; iOS 12.2; Scale/3.00)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "ios")), - Arguments.of("Signal-Android 4.68.3 (Android 9)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), - Arguments.of("Signal-Android 1.2.3 (Android 4.3)", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), - Arguments.of("Signal-Android 4.68.3.0-bobsbootlegclient", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "android")), - Arguments.of("Signal Desktop 1.22.45-foo-0", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), - Arguments.of("Signal Desktop 1.34.5-beta.1-fakeclientemporium", - Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")), - Arguments.of("Signal Desktop 1.32.0-beta.3", Tag.of(UserAgentTagUtil.PLATFORM_TAG, "desktop")) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/providers/MultiRecipientMessageProviderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/providers/MultiRecipientMessageProviderTest.java deleted file mode 100644 index ff3240759..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/providers/MultiRecipientMessageProviderTest.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.providers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.params.provider.Arguments.arguments; - -import java.io.ByteArrayInputStream; -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -public class MultiRecipientMessageProviderTest { - - static byte[] createTwoByteArray(int b1, int b2) { - return new byte[]{(byte) b1, (byte) b2}; - } - - static Stream readU16TestCases() { - return Stream.of( - arguments(0xFFFE, createTwoByteArray(0xFF, 0xFE)), - arguments(0x0001, createTwoByteArray(0x00, 0x01)), - arguments(0xBEEF, createTwoByteArray(0xBE, 0xEF)), - arguments(0xFFFF, createTwoByteArray(0xFF, 0xFF)), - arguments(0x0000, createTwoByteArray(0x00, 0x00)), - arguments(0xF080, createTwoByteArray(0xF0, 0x80)) - ); - } - - @ParameterizedTest - @MethodSource("readU16TestCases") - void testReadU16(int expectedValue, byte[] input) throws Exception { - try (final ByteArrayInputStream stream = new ByteArrayInputStream(input)) { - assertThat(MultiRecipientMessageProvider.readU16(stream)).isEqualTo(expectedValue); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java deleted file mode 100644 index 22d0162a5..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import com.eatthepath.pushy.apns.ApnsClient; -import com.eatthepath.pushy.apns.ApnsPushNotification; -import com.eatthepath.pushy.apns.DeliveryPriority; -import com.eatthepath.pushy.apns.PushNotificationResponse; -import com.eatthepath.pushy.apns.PushType; -import com.eatthepath.pushy.apns.util.SimpleApnsPushNotification; -import com.eatthepath.pushy.apns.util.concurrent.PushNotificationFuture; -import java.io.IOException; -import java.util.Optional; -import java.util.concurrent.CompletionException; -import java.util.concurrent.TimeUnit; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.tests.util.SynchronousExecutorService; - -class APNSenderTest { - - private static final String DESTINATION_DEVICE_TOKEN = RandomStringUtils.randomAlphanumeric(32); - private static final String BUNDLE_ID = "org.signal.test"; - - private Account destinationAccount; - private Device destinationDevice; - - private ApnsClient apnsClient; - private APNSender apnSender; - - @BeforeEach - void setup() { - destinationAccount = mock(Account.class); - destinationDevice = mock(Device.class); - - apnsClient = mock(ApnsClient.class); - apnSender = new APNSender(new SynchronousExecutorService(), apnsClient, BUNDLE_ID); - - when(destinationAccount.getDevice(1)).thenReturn(Optional.of(destinationDevice)); - when(destinationDevice.getApnId()).thenReturn(DESTINATION_DEVICE_TOKEN); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testSendVoip(final boolean urgent) { - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(true); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer( - (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN_VOIP, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, urgent); - - final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_DEVICE_TOKEN); - assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); - assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_VOIP_NOTIFICATION_PAYLOAD); - // Delivery priority should always be `IMMEDIATE` for VOIP notifications - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - assertThat(notification.getValue().getTopic()).isEqualTo(BUNDLE_ID + ".voip"); - - assertThat(result.accepted()).isTrue(); - assertThat(result.errorCode()).isNull(); - assertThat(result.unregistered()).isFalse(); - - verifyNoMoreInteractions(apnsClient); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testSendApns(final boolean urgent) { - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(true); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer( - (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, urgent); - - final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_DEVICE_TOKEN); - assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); - assertThat(notification.getValue().getPayload()) - .isEqualTo(urgent ? APNSender.APN_NSE_NOTIFICATION_PAYLOAD : APNSender.APN_BACKGROUND_PAYLOAD); - - assertThat(notification.getValue().getPriority()) - .isEqualTo(urgent ? DeliveryPriority.IMMEDIATE : DeliveryPriority.CONSERVE_POWER); - - assertThat(notification.getValue().getTopic()).isEqualTo(BUNDLE_ID); - assertThat(notification.getValue().getPushType()) - .isEqualTo(urgent ? PushType.ALERT : PushType.BACKGROUND); - - if (urgent) { - assertThat(notification.getValue().getCollapseId()).isNotNull(); - } else { - assertThat(notification.getValue().getCollapseId()).isNull(); - } - - assertThat(result.accepted()).isTrue(); - assertThat(result.errorCode()).isNull(); - assertThat(result.unregistered()).isFalse(); - - verifyNoMoreInteractions(apnsClient); - } - - @Test - void testUnregisteredUser() { - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(false); - when(response.getRejectionReason()).thenReturn(Optional.of("Unregistered")); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer( - (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN_VOIP, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true); - - when(destinationDevice.getApnId()).thenReturn(DESTINATION_DEVICE_TOKEN); - when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11)); - - final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_DEVICE_TOKEN); - assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); - assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_VOIP_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - - assertThat(result.accepted()).isFalse(); - assertThat(result.errorCode()).isEqualTo("Unregistered"); - assertThat(result.unregistered()).isTrue(); - } - - @Test - void testGenericFailure() { - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(false); - when(response.getRejectionReason()).thenReturn(Optional.of("BadTopic")); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer( - (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response)); - - PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN_VOIP, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true); - - final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join(); - - ArgumentCaptor notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class); - verify(apnsClient).sendNotification(notification.capture()); - - assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_DEVICE_TOKEN); - assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION); - assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_VOIP_NOTIFICATION_PAYLOAD); - assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE); - - assertThat(result.accepted()).isFalse(); - assertThat(result.errorCode()).isEqualTo("BadTopic"); - assertThat(result.unregistered()).isFalse(); - } - - @Test - void testFailure() { - PushNotificationResponse response = mock(PushNotificationResponse.class); - when(response.isAccepted()).thenReturn(true); - - when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class))) - .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), - new IOException("lost connection"))); - - PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN_VOIP, - PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true); - - assertThatThrownBy(() -> apnSender.sendNotification(pushNotification).join()) - .isInstanceOf(CompletionException.class) - .hasCauseInstanceOf(IOException.class); - - verify(apnsClient).sendNotification(any()); - - verifyNoMoreInteractions(apnsClient); - } - - private static class MockPushNotificationFuture

extends - PushNotificationFuture { - - MockPushNotificationFuture(final P pushNotification, final V response) { - super(pushNotification); - complete(response); - } - - MockPushNotificationFuture(final P pushNotification, final Exception exception) { - super(pushNotification); - completeExceptionally(exception); - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/ApnPushNotificationSchedulerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/ApnPushNotificationSchedulerTest.java deleted file mode 100644 index 1e1fc5e87..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/ApnPushNotificationSchedulerTest.java +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.lettuce.core.cluster.SlotHash; -import java.time.Clock; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.textsecuregcm.util.TestClock; - -class ApnPushNotificationSchedulerTest { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private Account account; - private Device device; - - private APNSender apnSender; - private TestClock clock; - - private ApnPushNotificationScheduler apnPushNotificationScheduler; - - private static final UUID ACCOUNT_UUID = UUID.randomUUID(); - private static final String ACCOUNT_NUMBER = "+18005551234"; - private static final long DEVICE_ID = 1L; - private static final String APN_ID = RandomStringUtils.randomAlphanumeric(32); - private static final String VOIP_APN_ID = RandomStringUtils.randomAlphanumeric(32); - - @BeforeEach - void setUp() throws Exception { - - device = mock(Device.class); - when(device.getId()).thenReturn(DEVICE_ID); - when(device.getApnId()).thenReturn(APN_ID); - when(device.getVoipApnId()).thenReturn(VOIP_APN_ID); - when(device.getLastSeen()).thenReturn(System.currentTimeMillis()); - - account = mock(Account.class); - when(account.getUuid()).thenReturn(ACCOUNT_UUID); - when(account.getNumber()).thenReturn(ACCOUNT_NUMBER); - when(account.getDevice(DEVICE_ID)).thenReturn(Optional.of(device)); - - final AccountsManager accountsManager = mock(AccountsManager.class); - when(accountsManager.getByE164(ACCOUNT_NUMBER)).thenReturn(Optional.of(account)); - when(accountsManager.getByAccountIdentifier(ACCOUNT_UUID)).thenReturn(Optional.of(account)); - - apnSender = mock(APNSender.class); - clock = TestClock.now(); - - apnPushNotificationScheduler = new ApnPushNotificationScheduler(REDIS_CLUSTER_EXTENSION.getRedisCluster(), apnSender, accountsManager, clock); - } - - @Test - void testClusterInsert() { - final String endpoint = ApnPushNotificationScheduler.getEndpointKey(account, device); - final long currentTimeMillis = System.currentTimeMillis(); - - assertTrue( - apnPushNotificationScheduler.getPendingDestinationsForRecurringVoipNotifications(SlotHash.getSlot(endpoint), 1).isEmpty()); - - clock.pin(Instant.ofEpochMilli(currentTimeMillis - 30_000)); - apnPushNotificationScheduler.scheduleRecurringVoipNotification(account, device); - - clock.pin(Instant.ofEpochMilli(currentTimeMillis)); - final List pendingDestinations = apnPushNotificationScheduler.getPendingDestinationsForRecurringVoipNotifications(SlotHash.getSlot(endpoint), 2); - assertEquals(1, pendingDestinations.size()); - - final Optional> maybeUuidAndDeviceId = ApnPushNotificationScheduler.getSeparated( - pendingDestinations.get(0)); - - assertTrue(maybeUuidAndDeviceId.isPresent()); - assertEquals(ACCOUNT_UUID.toString(), maybeUuidAndDeviceId.get().first()); - assertEquals(DEVICE_ID, (long) maybeUuidAndDeviceId.get().second()); - - assertTrue( - apnPushNotificationScheduler.getPendingDestinationsForRecurringVoipNotifications(SlotHash.getSlot(endpoint), 1).isEmpty()); - } - - @Test - void testProcessRecurringVoipNotifications() { - final ApnPushNotificationScheduler.NotificationWorker worker = apnPushNotificationScheduler.new NotificationWorker(); - final long currentTimeMillis = System.currentTimeMillis(); - - clock.pin(Instant.ofEpochMilli(currentTimeMillis - 30_000)); - apnPushNotificationScheduler.scheduleRecurringVoipNotification(account, device); - - clock.pin(Instant.ofEpochMilli(currentTimeMillis)); - - final int slot = SlotHash.getSlot(ApnPushNotificationScheduler.getEndpointKey(account, device)); - - assertEquals(1, worker.processRecurringVoipNotifications(slot)); - - final ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(PushNotification.class); - verify(apnSender).sendNotification(notificationCaptor.capture()); - - final PushNotification pushNotification = notificationCaptor.getValue(); - - assertEquals(VOIP_APN_ID, pushNotification.deviceToken()); - assertEquals(account, pushNotification.destination()); - assertEquals(device, pushNotification.destinationDevice()); - - assertEquals(0, worker.processRecurringVoipNotifications(slot)); - } - - @Test - void testScheduleBackgroundNotificationWithNoRecentNotification() { - final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); - clock.pin(now); - - assertEquals(Optional.empty(), - apnPushNotificationScheduler.getLastBackgroundNotificationTimestamp(account, device)); - - assertEquals(Optional.empty(), - apnPushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(account, device)); - - apnPushNotificationScheduler.scheduleBackgroundNotification(account, device); - - assertEquals(Optional.of(now), - apnPushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(account, device)); - } - - @Test - void testScheduleBackgroundNotificationWithRecentNotification() { - final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); - final Instant recentNotificationTimestamp = - now.minus(ApnPushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD.dividedBy(2)); - - // Insert a timestamp for a recently-sent background push notification - clock.pin(Instant.ofEpochMilli(recentNotificationTimestamp.toEpochMilli())); - apnPushNotificationScheduler.sendBackgroundNotification(account, device); - - clock.pin(now); - apnPushNotificationScheduler.scheduleBackgroundNotification(account, device); - - final Instant expectedScheduledTimestamp = - recentNotificationTimestamp.plus(ApnPushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD); - - assertEquals(Optional.of(expectedScheduledTimestamp), - apnPushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(account, device)); - } - - @Test - void testProcessScheduledBackgroundNotifications() { - final ApnPushNotificationScheduler.NotificationWorker worker = apnPushNotificationScheduler.new NotificationWorker(); - - final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); - - clock.pin(Instant.ofEpochMilli(now.toEpochMilli())); - apnPushNotificationScheduler.scheduleBackgroundNotification(account, device); - - final int slot = - SlotHash.getSlot(ApnPushNotificationScheduler.getPendingBackgroundNotificationQueueKey(account, device)); - - clock.pin(Instant.ofEpochMilli(now.minusMillis(1).toEpochMilli())); - assertEquals(0, worker.processScheduledBackgroundNotifications(slot)); - - clock.pin(now); - assertEquals(1, worker.processScheduledBackgroundNotifications(slot)); - - final ArgumentCaptor notificationCaptor = ArgumentCaptor.forClass(PushNotification.class); - verify(apnSender).sendNotification(notificationCaptor.capture()); - - final PushNotification pushNotification = notificationCaptor.getValue(); - - assertEquals(PushNotification.TokenType.APN, pushNotification.tokenType()); - assertEquals(APN_ID, pushNotification.deviceToken()); - assertEquals(account, pushNotification.destination()); - assertEquals(device, pushNotification.destinationDevice()); - assertEquals(PushNotification.NotificationType.NOTIFICATION, pushNotification.notificationType()); - assertFalse(pushNotification.urgent()); - - assertEquals(0, worker.processRecurringVoipNotifications(slot)); - } - - @Test - void testProcessScheduledBackgroundNotificationsCancelled() { - final ApnPushNotificationScheduler.NotificationWorker worker = apnPushNotificationScheduler.new NotificationWorker(); - - final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); - - clock.pin(now); - apnPushNotificationScheduler.scheduleBackgroundNotification(account, device); - apnPushNotificationScheduler.cancelScheduledNotifications(account, device); - - final int slot = - SlotHash.getSlot(ApnPushNotificationScheduler.getPendingBackgroundNotificationQueueKey(account, device)); - - assertEquals(0, worker.processScheduledBackgroundNotifications(slot)); - - verify(apnSender, never()).sendNotification(any()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/ClientPresenceManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/ClientPresenceManagerTest.java deleted file mode 100644 index 2c628f510..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/ClientPresenceManagerTest.java +++ /dev/null @@ -1,375 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; - -import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; -import io.lettuce.core.cluster.event.ClusterTopologyChangedEvent; -import java.time.Duration; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; - -class ClientPresenceManagerTest { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private ScheduledExecutorService presenceRenewalExecutorService; - private ClientPresenceManager clientPresenceManager; - - private static final DisplacedPresenceListener NO_OP = connectedElsewhere -> { - }; - - @BeforeEach - void setUp() throws Exception { - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> { - connection.sync().flushall(); - connection.sync().upstream().commands().configSet("notify-keyspace-events", "K$glz"); - }); - - presenceRenewalExecutorService = Executors.newSingleThreadScheduledExecutor(); - clientPresenceManager = new ClientPresenceManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - presenceRenewalExecutorService, - presenceRenewalExecutorService); - } - - @AfterEach - public void tearDown() throws Exception { - presenceRenewalExecutorService.shutdown(); - presenceRenewalExecutorService.awaitTermination(1, TimeUnit.MINUTES); - - clientPresenceManager.stop(); - } - - @Test - void testIsPresent() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - assertFalse(clientPresenceManager.isPresent(accountUuid, deviceId)); - - clientPresenceManager.setPresent(accountUuid, deviceId, NO_OP); - assertTrue(clientPresenceManager.isPresent(accountUuid, deviceId)); - } - - @Test - void testIsLocallyPresent() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - assertFalse(clientPresenceManager.isLocallyPresent(accountUuid, deviceId)); - - clientPresenceManager.setPresent(accountUuid, deviceId, NO_OP); - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> connection.sync().flushall()); - - assertTrue(clientPresenceManager.isLocallyPresent(accountUuid, deviceId)); - } - - @Test - void testLocalDisplacement() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - final AtomicInteger displacementCounter = new AtomicInteger(0); - final DisplacedPresenceListener displacementListener = connectedElsewhere -> displacementCounter.incrementAndGet(); - - clientPresenceManager.setPresent(accountUuid, deviceId, displacementListener); - - assertEquals(0, displacementCounter.get()); - - clientPresenceManager.setPresent(accountUuid, deviceId, displacementListener); - - assertEquals(1, displacementCounter.get()); - } - - @Test - void testRemoteDisplacement() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - final CompletableFuture displaced = new CompletableFuture<>(); - - clientPresenceManager.start(); - - clientPresenceManager.setPresent(accountUuid, deviceId, connectedElsewhere -> displaced.complete(null)); - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster( - connection -> connection.sync().set(ClientPresenceManager.getPresenceKey(accountUuid, deviceId), - UUID.randomUUID().toString())); - - assertTimeoutPreemptively(Duration.ofSeconds(10), displaced::join); - } - - @Test - void testRemoteDisplacementAfterTopologyChange() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - final CompletableFuture displaced = new CompletableFuture<>(); - - clientPresenceManager.start(); - - clientPresenceManager.setPresent(accountUuid, deviceId, connectedElsewhere -> displaced.complete(null)); - - clientPresenceManager.getPubSubConnection() - .usePubSubConnection(connection -> connection.getResources().eventBus() - .publish(new ClusterTopologyChangedEvent(List.of(), List.of()))); - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster( - connection -> connection.sync().set(ClientPresenceManager.getPresenceKey(accountUuid, deviceId), - UUID.randomUUID().toString())); - - assertTimeoutPreemptively(Duration.ofSeconds(10), displaced::join); - } - - @Test - void testClearPresence() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - assertFalse(clientPresenceManager.isPresent(accountUuid, deviceId)); - - clientPresenceManager.setPresent(accountUuid, deviceId, NO_OP); - assertTrue(clientPresenceManager.clearPresence(accountUuid, deviceId)); - - clientPresenceManager.setPresent(accountUuid, deviceId, NO_OP); - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster( - connection -> connection.sync().set(ClientPresenceManager.getPresenceKey(accountUuid, deviceId), - UUID.randomUUID().toString())); - - assertFalse(clientPresenceManager.clearPresence(accountUuid, deviceId)); - } - - @Test - void testPruneMissingPeers() { - final String presentPeerId = UUID.randomUUID().toString(); - final String missingPeerId = UUID.randomUUID().toString(); - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> { - connection.sync().sadd(ClientPresenceManager.MANAGER_SET_KEY, presentPeerId); - connection.sync().sadd(ClientPresenceManager.MANAGER_SET_KEY, missingPeerId); - }); - - for (int i = 0; i < 10; i++) { - addClientPresence(presentPeerId); - addClientPresence(missingPeerId); - } - - clientPresenceManager.getPubSubConnection().usePubSubConnection( - connection -> connection.sync().upstream().commands() - .subscribe(ClientPresenceManager.getManagerPresenceChannel(presentPeerId))); - clientPresenceManager.pruneMissingPeers(); - - assertEquals(1, (long) REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster( - connection -> connection.sync().exists(ClientPresenceManager.getConnectedClientSetKey(presentPeerId)))); - assertTrue(REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster( - (Function, Boolean>) connection -> connection.sync() - .sismember(ClientPresenceManager.MANAGER_SET_KEY, presentPeerId))); - - assertEquals(0, (long) REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster( - connection -> connection.sync().exists(ClientPresenceManager.getConnectedClientSetKey(missingPeerId)))); - assertFalse(REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster( - (Function, Boolean>) connection -> connection.sync() - .sismember(ClientPresenceManager.MANAGER_SET_KEY, missingPeerId))); - } - - @Test - void testInitialPresenceExpiration() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - clientPresenceManager.setPresent(accountUuid, deviceId, NO_OP); - - { - final int ttl = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> - connection.sync().ttl(ClientPresenceManager.getPresenceKey(accountUuid, deviceId)).intValue()); - - assertTrue(ttl > 0); - } - } - - @Test - void testRenewPresence() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - final String presenceKey = ClientPresenceManager.getPresenceKey(accountUuid, deviceId); - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> - connection.sync().set(presenceKey, clientPresenceManager.getManagerId())); - - { - final int ttl = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> - connection.sync().ttl(presenceKey).intValue()); - - assertEquals(-1, ttl); - } - - clientPresenceManager.renewPresence(accountUuid, deviceId); - - { - final int ttl = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> - connection.sync().ttl(presenceKey).intValue()); - - assertTrue(ttl > 0); - } - } - - @Test - void testExpiredPresence() { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - clientPresenceManager.setPresent(accountUuid, deviceId, NO_OP); - - assertTrue(clientPresenceManager.isPresent(accountUuid, deviceId)); - - // Hackily set this key to expire immediately - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> - connection.sync().expire(ClientPresenceManager.getPresenceKey(accountUuid, deviceId), 0)); - - assertFalse(clientPresenceManager.isPresent(accountUuid, deviceId)); - } - - private void addClientPresence(final String managerId) { - final String clientPresenceKey = ClientPresenceManager.getPresenceKey(UUID.randomUUID(), 7); - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> { - connection.sync().set(clientPresenceKey, managerId); - connection.sync().sadd(ClientPresenceManager.getConnectedClientSetKey(managerId), clientPresenceKey); - }); - } - - @Test - void testClearAllOnStop() { - final int localAccounts = 10; - final UUID[] localUuids = new UUID[localAccounts]; - final long[] localDeviceIds = new long[localAccounts]; - - for (int i = 0; i < localAccounts; i++) { - localUuids[i] = UUID.randomUUID(); - localDeviceIds[i] = i; - - clientPresenceManager.setPresent(localUuids[i], localDeviceIds[i], NO_OP); - } - - final UUID displacedAccountUuid = UUID.randomUUID(); - final long displacedAccountDeviceId = 7; - - clientPresenceManager.setPresent(displacedAccountUuid, displacedAccountDeviceId, NO_OP); - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> connection.sync() - .set(ClientPresenceManager.getPresenceKey(displacedAccountUuid, displacedAccountDeviceId), - UUID.randomUUID().toString())); - - clientPresenceManager.stop(); - - for (int i = 0; i < localAccounts; i++) { - localUuids[i] = UUID.randomUUID(); - localDeviceIds[i] = i; - - assertFalse(clientPresenceManager.isPresent(localUuids[i], localDeviceIds[i])); - } - - assertTrue(clientPresenceManager.isPresent(displacedAccountUuid, displacedAccountDeviceId)); - } - - @Nested - class MultiServerTest { - - private ClientPresenceManager server1; - private ClientPresenceManager server2; - - @BeforeEach - void setup() throws Exception { - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> { - connection.sync().flushall(); - connection.sync().upstream().commands().configSet("notify-keyspace-events", "K$glz"); - }); - - final ScheduledExecutorService scheduledExecutorService1 = mock(ScheduledExecutorService.class); - final ExecutorService keyspaceNotificationExecutorService1 = Executors.newSingleThreadExecutor(); - server1 = new ClientPresenceManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - scheduledExecutorService1, keyspaceNotificationExecutorService1); - - final ScheduledExecutorService scheduledExecutorService2 = mock(ScheduledExecutorService.class); - final ExecutorService keyspaceNotificationExecutorService2 = Executors.newSingleThreadExecutor(); - server2 = new ClientPresenceManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - scheduledExecutorService2, keyspaceNotificationExecutorService2); - - server1.start(); - server2.start(); - } - - @AfterEach - void teardown() { - server2.stop(); - server1.stop(); - } - - @Test - void testSetPresentRemotely() { - final UUID uuid1 = UUID.randomUUID(); - final long deviceId = 1L; - - final CompletableFuture displaced = new CompletableFuture<>(); - final DisplacedPresenceListener listener1 = connectedElsewhere -> displaced.complete(null); - server1.setPresent(uuid1, deviceId, listener1); - - server2.setPresent(uuid1, deviceId, connectedElsewhere -> {}); - - assertTimeoutPreemptively(Duration.ofSeconds(10), displaced::join); - } - - @Test - void testDisconnectPresenceLocally() { - final UUID uuid1 = UUID.randomUUID(); - final long deviceId = 1L; - - final CompletableFuture displaced = new CompletableFuture<>(); - final DisplacedPresenceListener listener1 = connectedElsewhere -> displaced.complete(null); - server1.setPresent(uuid1, deviceId, listener1); - - server1.disconnectPresence(uuid1, deviceId); - - assertTimeoutPreemptively(Duration.ofSeconds(10), displaced::join); - } - - @Test - void testDisconnectPresenceRemotely() { - final UUID uuid1 = UUID.randomUUID(); - final long deviceId = 1L; - - final CompletableFuture displaced = new CompletableFuture<>(); - final DisplacedPresenceListener listener1 = connectedElsewhere -> displaced.complete(null); - server1.setPresent(uuid1, deviceId, listener1); - - server2.disconnectPresence(uuid1, deviceId); - - assertTimeoutPreemptively(Duration.ofSeconds(10), displaced::join); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java deleted file mode 100644 index dcc871bb2..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.api.core.SettableApiFuture; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.MessagingErrorCode; -import java.io.IOException; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.tests.util.SynchronousExecutorService; - -class FcmSenderTest { - - private ExecutorService executorService; - private FirebaseMessaging firebaseMessaging; - - private FcmSender fcmSender; - - @BeforeEach - void setUp() { - executorService = new SynchronousExecutorService(); - firebaseMessaging = mock(FirebaseMessaging.class); - - fcmSender = new FcmSender(executorService, firebaseMessaging); - } - - @AfterEach - void tearDown() throws InterruptedException { - executorService.shutdown(); - - //noinspection ResultOfMethodCallIgnored - executorService.awaitTermination(1, TimeUnit.SECONDS); - } - - @Test - void testSendMessage() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); - - final SettableApiFuture sendFuture = SettableApiFuture.create(); - sendFuture.set("message-id"); - - when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); - - final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join(); - - verify(firebaseMessaging).sendAsync(any(Message.class)); - assertTrue(result.accepted()); - assertNull(result.errorCode()); - assertFalse(result.unregistered()); - } - - @Test - void testSendMessageRejected() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); - - final FirebaseMessagingException invalidArgumentException = mock(FirebaseMessagingException.class); - when(invalidArgumentException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.INVALID_ARGUMENT); - - final SettableApiFuture sendFuture = SettableApiFuture.create(); - sendFuture.setException(invalidArgumentException); - - when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); - - final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join(); - - verify(firebaseMessaging).sendAsync(any(Message.class)); - assertFalse(result.accepted()); - assertEquals("INVALID_ARGUMENT", result.errorCode()); - assertFalse(result.unregistered()); - } - - @Test - void testSendMessageUnregistered() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); - - final FirebaseMessagingException unregisteredException = mock(FirebaseMessagingException.class); - when(unregisteredException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED); - - final SettableApiFuture sendFuture = SettableApiFuture.create(); - sendFuture.setException(unregisteredException); - - when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); - - final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join(); - - verify(firebaseMessaging).sendAsync(any(Message.class)); - assertFalse(result.accepted()); - assertEquals("UNREGISTERED", result.errorCode()); - assertTrue(result.unregistered()); - } - - @Test - void testSendMessageException() { - final PushNotification pushNotification = new PushNotification("foo", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true); - - final SettableApiFuture sendFuture = SettableApiFuture.create(); - sendFuture.setException(new IOException()); - - when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture); - - final CompletionException completionException = - assertThrows(CompletionException.class, () -> fcmSender.sendNotification(pushNotification).join()); - - verify(firebaseMessaging).sendAsync(any(Message.class)); - assertTrue(completionException.getCause() instanceof IOException); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java deleted file mode 100644 index 87cb32832..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import com.google.protobuf.ByteString; -import java.util.UUID; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.MessagesManager; - -class MessageSenderTest { - - private Account account; - private Device device; - private MessageProtos.Envelope message; - - private ClientPresenceManager clientPresenceManager; - private MessagesManager messagesManager; - private PushNotificationManager pushNotificationManager; - private MessageSender messageSender; - - private static final UUID ACCOUNT_UUID = UUID.randomUUID(); - private static final long DEVICE_ID = 1L; - - @BeforeEach - void setUp() { - - account = mock(Account.class); - device = mock(Device.class); - message = generateRandomMessage(); - - clientPresenceManager = mock(ClientPresenceManager.class); - messagesManager = mock(MessagesManager.class); - pushNotificationManager = mock(PushNotificationManager.class); - messageSender = new MessageSender(clientPresenceManager, - messagesManager, - pushNotificationManager, - mock(PushLatencyManager.class)); - - when(account.getUuid()).thenReturn(ACCOUNT_UUID); - when(device.getId()).thenReturn(DEVICE_ID); - } - - @Test - void testSendOnlineMessageClientPresent() throws Exception { - when(clientPresenceManager.isPresent(ACCOUNT_UUID, DEVICE_ID)).thenReturn(true); - when(device.getGcmId()).thenReturn("gcm-id"); - - messageSender.sendMessage(account, device, message, true); - - ArgumentCaptor envelopeArgumentCaptor = ArgumentCaptor.forClass( - MessageProtos.Envelope.class); - - verify(messagesManager).insert(any(), anyLong(), envelopeArgumentCaptor.capture()); - - assertTrue(envelopeArgumentCaptor.getValue().getEphemeral()); - - verifyNoInteractions(pushNotificationManager); - } - - @Test - void testSendOnlineMessageClientNotPresent() throws Exception { - when(clientPresenceManager.isPresent(ACCOUNT_UUID, DEVICE_ID)).thenReturn(false); - when(device.getGcmId()).thenReturn("gcm-id"); - - messageSender.sendMessage(account, device, message, true); - - verify(messagesManager, never()).insert(any(), anyLong(), any()); - verifyNoInteractions(pushNotificationManager); - } - - @Test - void testSendMessageClientPresent() throws Exception { - when(clientPresenceManager.isPresent(ACCOUNT_UUID, DEVICE_ID)).thenReturn(true); - when(device.getGcmId()).thenReturn("gcm-id"); - - messageSender.sendMessage(account, device, message, false); - - final ArgumentCaptor envelopeArgumentCaptor = ArgumentCaptor.forClass( - MessageProtos.Envelope.class); - - verify(messagesManager).insert(eq(ACCOUNT_UUID), eq(DEVICE_ID), envelopeArgumentCaptor.capture()); - - assertFalse(envelopeArgumentCaptor.getValue().getEphemeral()); - assertEquals(message, envelopeArgumentCaptor.getValue()); - verifyNoInteractions(pushNotificationManager); - } - - @Test - void testSendMessageGcmClientNotPresent() throws Exception { - when(clientPresenceManager.isPresent(ACCOUNT_UUID, DEVICE_ID)).thenReturn(false); - when(device.getGcmId()).thenReturn("gcm-id"); - - messageSender.sendMessage(account, device, message, false); - - verify(messagesManager).insert(ACCOUNT_UUID, DEVICE_ID, message); - verify(pushNotificationManager).sendNewMessageNotification(account, device.getId(), message.getUrgent()); - } - - @Test - void testSendMessageApnClientNotPresent() throws Exception { - when(clientPresenceManager.isPresent(ACCOUNT_UUID, DEVICE_ID)).thenReturn(false); - when(device.getApnId()).thenReturn("apn-id"); - - messageSender.sendMessage(account, device, message, false); - - verify(messagesManager).insert(ACCOUNT_UUID, DEVICE_ID, message); - verify(pushNotificationManager).sendNewMessageNotification(account, device.getId(), message.getUrgent()); - } - - @Test - void testSendMessageFetchClientNotPresent() throws Exception { - when(clientPresenceManager.isPresent(ACCOUNT_UUID, DEVICE_ID)).thenReturn(false); - when(device.getFetchesMessages()).thenReturn(true); - - doThrow(NotPushRegisteredException.class) - .when(pushNotificationManager).sendNewMessageNotification(account, DEVICE_ID, message.getUrgent()); - - assertDoesNotThrow(() -> messageSender.sendMessage(account, device, message, false)); - verify(messagesManager).insert(ACCOUNT_UUID, DEVICE_ID, message); - } - - private MessageProtos.Envelope generateRandomMessage() { - return MessageProtos.Envelope.newBuilder() - .setTimestamp(System.currentTimeMillis()) - .setServerTimestamp(System.currentTimeMillis()) - .setContent(ByteString.copyFromUtf8(RandomStringUtils.randomAlphanumeric(256))) - .setType(MessageProtos.Envelope.Type.CIPHERTEXT) - .setServerGuid(UUID.randomUUID().toString()) - .build(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/PushLatencyManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/PushLatencyManagerTest.java deleted file mode 100644 index bbf82f8c7..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/PushLatencyManagerTest.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.util.Collections; -import java.util.UUID; -import java.util.concurrent.ExecutionException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPushLatencyConfiguration; -import org.whispersystems.textsecuregcm.push.PushLatencyManager; -import org.whispersystems.textsecuregcm.push.PushLatencyManager.PushRecord; -import org.whispersystems.textsecuregcm.push.PushLatencyManager.PushType; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -class PushLatencyManagerTest { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private DynamicConfigurationManager dynamicConfigurationManager; - - @BeforeEach - void setUp() { - //noinspection unchecked - dynamicConfigurationManager = mock(DynamicConfigurationManager.class); - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - final DynamicPushLatencyConfiguration dynamicPushLatencyConfiguration = mock(DynamicPushLatencyConfiguration.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - when(dynamicConfiguration.getPushLatencyConfiguration()).thenReturn(dynamicPushLatencyConfiguration); - when(dynamicPushLatencyConfiguration.getInstrumentedVersions()).thenReturn(Collections.emptyMap()); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testTakeRecord(final boolean isVoip) throws ExecutionException, InterruptedException { - final UUID accountUuid = UUID.randomUUID(); - final long deviceId = 1; - - final Instant pushTimestamp = Instant.now(); - - final PushLatencyManager pushLatencyManager = new PushLatencyManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - dynamicConfigurationManager, Clock.fixed(pushTimestamp, ZoneId.systemDefault())); - - assertNull(pushLatencyManager.takePushRecord(accountUuid, deviceId).get()); - - pushLatencyManager.recordPushSent(accountUuid, deviceId, isVoip); - - final PushRecord pushRecord = pushLatencyManager.takePushRecord(accountUuid, deviceId).get(); - - assertNotNull(pushRecord); - assertEquals(pushTimestamp, pushRecord.getTimestamp()); - assertEquals(isVoip ? PushType.VOIP : PushType.STANDARD, pushRecord.getPushType()); - - assertNull(pushLatencyManager.takePushRecord(accountUuid, deviceId).get()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java deleted file mode 100644 index 858643c55..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.push; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import com.google.common.net.HttpHeaders; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPushNotificationConfiguration; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.util.Util; - -class PushNotificationManagerTest { - - private AccountsManager accountsManager; - private APNSender apnSender; - private FcmSender fcmSender; - private ApnPushNotificationScheduler apnPushNotificationScheduler; - private PushLatencyManager pushLatencyManager; - private DynamicPushNotificationConfiguration pushNotificationConfiguration; - - private PushNotificationManager pushNotificationManager; - - @BeforeEach - void setUp() { - accountsManager = mock(AccountsManager.class); - apnSender = mock(APNSender.class); - fcmSender = mock(FcmSender.class); - apnPushNotificationScheduler = mock(ApnPushNotificationScheduler.class); - pushLatencyManager = mock(PushLatencyManager.class); - pushNotificationConfiguration = mock(DynamicPushNotificationConfiguration.class); - - @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = - mock(DynamicConfigurationManager.class); - - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - when(dynamicConfiguration.getPushNotificationConfiguration()).thenReturn(pushNotificationConfiguration); - when(pushNotificationConfiguration.isLowUrgencyEnabled()).thenReturn(true); - - AccountsHelper.setupMockUpdate(accountsManager); - - pushNotificationManager = new PushNotificationManager(accountsManager, apnSender, fcmSender, - apnPushNotificationScheduler, pushLatencyManager, dynamicConfigurationManager); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void sendNewMessageNotification(final boolean urgent) throws NotPushRegisteredException { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - final String deviceToken = "token"; - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(device.getGcmId()).thenReturn(deviceToken); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - when(fcmSender.sendNotification(any())) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); - - pushNotificationManager.sendNewMessageNotification(account, Device.MASTER_ID, urgent); - verify(fcmSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void sendNewMessageNotificationLowUrgencyDisabled(final boolean urgent) throws NotPushRegisteredException { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - final String deviceToken = "token"; - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(device.getApnId()).thenReturn(deviceToken); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - when(pushNotificationConfiguration.isLowUrgencyEnabled()).thenReturn(false); - - when(apnSender.sendNotification(any())) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); - - pushNotificationManager.sendNewMessageNotification(account, Device.MASTER_ID, urgent); - - verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, true)); - } - - @Test - void sendRegistrationChallengeNotification() { - final String deviceToken = "token"; - final String challengeToken = "challenge"; - - when(apnSender.sendNotification(any())) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); - - pushNotificationManager.sendRegistrationChallengeNotification(deviceToken, PushNotification.TokenType.APN_VOIP, challengeToken); - verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true)); - } - - @Test - void sendRateLimitChallengeNotification() throws NotPushRegisteredException { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - final String deviceToken = "token"; - final String challengeToken = "challenge"; - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(device.getApnId()).thenReturn(deviceToken); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - when(apnSender.sendNotification(any())) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); - - pushNotificationManager.sendRateLimitChallengeNotification(account, challengeToken); - verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, account, device, true)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testSendNotificationFcm(final boolean urgent) { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent); - - when(fcmSender.sendNotification(pushNotification)) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); - - pushNotificationManager.sendNotification(pushNotification); - - verify(fcmSender).sendNotification(pushNotification); - verifyNoInteractions(apnSender); - verify(accountsManager, never()).updateDevice(eq(account), eq(Device.MASTER_ID), any()); - verify(device, never()).setUninstalledFeedbackTimestamp(Util.todayInMillis()); - verifyNoInteractions(apnPushNotificationScheduler); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testSendNotificationApn(final boolean urgent) { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent); - - when(apnSender.sendNotification(pushNotification)) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); - - pushNotificationManager.sendNotification(pushNotification); - - verifyNoInteractions(fcmSender); - - if (urgent) { - verify(apnSender).sendNotification(pushNotification); - verifyNoInteractions(apnPushNotificationScheduler); - } else { - verifyNoInteractions(apnSender); - verify(apnPushNotificationScheduler).scheduleBackgroundNotification(account, device); - } - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testSendNotificationApnVoip(final boolean urgent) { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent); - - when(apnSender.sendNotification(pushNotification)) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, null, false))); - - pushNotificationManager.sendNotification(pushNotification); - - verify(apnSender).sendNotification(pushNotification); - - verifyNoInteractions(fcmSender); - verify(accountsManager, never()).updateDevice(eq(account), eq(Device.MASTER_ID), any()); - verify(device, never()).setUninstalledFeedbackTimestamp(Util.todayInMillis()); - verify(apnPushNotificationScheduler).scheduleRecurringVoipNotification(account, device); - verify(apnPushNotificationScheduler, never()).scheduleBackgroundNotification(any(), any()); - } - - @Test - void testSendNotificationUnregisteredFcm() { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(device.getGcmId()).thenReturn("token"); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, true); - - when(fcmSender.sendNotification(pushNotification)) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, null, true))); - - pushNotificationManager.sendNotification(pushNotification); - - verify(accountsManager).updateDevice(eq(account), eq(Device.MASTER_ID), any()); - verify(device).setUninstalledFeedbackTimestamp(Util.todayInMillis()); - verifyNoInteractions(apnSender); - verifyNoInteractions(apnPushNotificationScheduler); - } - - @Test - void testSendNotificationUnregisteredApn() { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - when(device.getId()).thenReturn(Device.MASTER_ID); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - final PushNotification pushNotification = new PushNotification( - "token", PushNotification.TokenType.APN_VOIP, PushNotification.NotificationType.NOTIFICATION, null, account, device, true); - - when(apnSender.sendNotification(pushNotification)) - .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, null, true))); - - pushNotificationManager.sendNotification(pushNotification); - - verifyNoInteractions(fcmSender); - verify(accountsManager, never()).updateDevice(eq(account), eq(Device.MASTER_ID), any()); - verify(device, never()).setUninstalledFeedbackTimestamp(Util.todayInMillis()); - verify(apnPushNotificationScheduler).cancelScheduledNotifications(account, device); - } - - @Test - void testHandleMessagesRetrieved() { - final UUID accountIdentifier = UUID.randomUUID(); - final Account account = mock(Account.class); - final Device device = mock(Device.class); - final String userAgent = HttpHeaders.USER_AGENT; - - when(account.getUuid()).thenReturn(accountIdentifier); - when(device.getId()).thenReturn(Device.MASTER_ID); - - pushNotificationManager.handleMessagesRetrieved(account, device, userAgent); - - verify(pushLatencyManager).recordQueueRead(accountIdentifier, Device.MASTER_ID, userAgent); - verify(apnPushNotificationScheduler).cancelScheduledNotifications(account, device); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScriptTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScriptTest.java deleted file mode 100644 index 98febfce7..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScriptTest.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.lettuce.core.FlushMode; -import io.lettuce.core.RedisFuture; -import io.lettuce.core.RedisNoScriptException; -import io.lettuce.core.ScriptOutputType; -import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands; -import io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import io.lettuce.core.protocol.AsyncCommand; -import io.lettuce.core.protocol.RedisCommand; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper; -import reactor.core.publisher.Flux; - -class ClusterLuaScriptTest { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - @Test - void testExecute() { - final RedisAdvancedClusterCommands commands = mock(RedisAdvancedClusterCommands.class); - final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder().stringCommands(commands).build(); - - final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])"; - final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE; - final List keys = List.of("key"); - final List values = List.of("value"); - - when(commands.evalsha(any(), any(), any(), any())).thenReturn("OK"); - - final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType); - luaScript.execute(keys, values); - - verify(commands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new String[0]), values.toArray(new String[0])); - verify(commands, never()).eval(anyString(), any(), any(), any()); - } - - @Test - void testExecuteScriptNotLoaded() { - final RedisAdvancedClusterCommands commands = mock(RedisAdvancedClusterCommands.class); - final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder().stringCommands(commands).build(); - - final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])"; - final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE; - final List keys = List.of("key"); - final List values = List.of("value"); - - when(commands.evalsha(any(), any(), any(), any())).thenThrow(new RedisNoScriptException("OH NO")); - - final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType); - luaScript.execute(keys, values); - - verify(commands).eval(script, scriptOutputType, keys.toArray(new String[0]), values.toArray(new String[0])); - verify(commands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new String[0]), values.toArray(new String[0])); - } - - @Test - void testExecuteBinaryScriptNotLoaded() { - final RedisAdvancedClusterCommands stringCommands = mock(RedisAdvancedClusterCommands.class); - final RedisAdvancedClusterCommands binaryCommands = mock(RedisAdvancedClusterCommands.class); - final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder() - .stringCommands(stringCommands) - .binaryCommands(binaryCommands) - .build(); - - final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])"; - final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE; - final List keys = List.of("key".getBytes(StandardCharsets.UTF_8)); - final List values = List.of("value".getBytes(StandardCharsets.UTF_8)); - - when(binaryCommands.evalsha(any(), any(), any(), any())).thenThrow(new RedisNoScriptException("OH NO")); - - final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType); - luaScript.executeBinary(keys, values); - - verify(binaryCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]), values.toArray(new byte[0][])); - verify(binaryCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]), - values.toArray(new byte[0][])); - } - - @Test - void testExecuteBinaryAsyncScriptNotLoaded() throws Exception { - final RedisAdvancedClusterAsyncCommands binaryAsyncCommands = - mock(RedisAdvancedClusterAsyncCommands.class); - final FaultTolerantRedisCluster mockCluster = - RedisClusterHelper.builder().binaryAsyncCommands(binaryAsyncCommands).build(); - - final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])"; - final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE; - final List keys = List.of("key".getBytes(StandardCharsets.UTF_8)); - final List values = List.of("value".getBytes(StandardCharsets.UTF_8)); - - final AsyncCommand evalShaFailure = new AsyncCommand<>(mock(RedisCommand.class)); - evalShaFailure.completeExceptionally(new RedisNoScriptException("OH NO")); - - final AsyncCommand evalSuccess = new AsyncCommand<>(mock(RedisCommand.class)); - evalSuccess.complete(); - - when(binaryAsyncCommands.evalsha(any(), any(), any(), any())).thenReturn((RedisFuture) evalShaFailure); - when(binaryAsyncCommands.eval(anyString(), any(), any(), any())).thenReturn((RedisFuture) evalSuccess); - - final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType); - luaScript.executeBinaryAsync(keys, values).get(5, TimeUnit.SECONDS); - - verify(binaryAsyncCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]), - values.toArray(new byte[0][])); - verify(binaryAsyncCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]), - values.toArray(new byte[0][])); - } - - @Test - void testExecuteBinaryReactiveScriptNotLoaded() { - final RedisAdvancedClusterReactiveCommands binaryReactiveCommands = - mock(RedisAdvancedClusterReactiveCommands.class); - final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder() - .binaryReactiveCommands(binaryReactiveCommands).build(); - - final String script = "return redis.call(\"SET\", KEYS[1], ARGV[1])"; - final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE; - final List keys = List.of("key".getBytes(StandardCharsets.UTF_8)); - final List values = List.of("value".getBytes(StandardCharsets.UTF_8)); - - when(binaryReactiveCommands.evalsha(any(), any(), any(), any())) - .thenReturn(Flux.error(new RedisNoScriptException("OH NO"))); - when(binaryReactiveCommands.eval(anyString(), any(), any(), any())).thenReturn(Flux.just("ok")); - - final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType); - luaScript.executeBinaryReactive(keys, values).blockLast(Duration.ofSeconds(5)); - - verify(binaryReactiveCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]), - values.toArray(new byte[0][])); - verify(binaryReactiveCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]), - values.toArray(new byte[0][])); - } - - @ParameterizedTest - @EnumSource(ExecuteMode.class) - void testExecuteRealCluster(final ExecuteMode mode) throws Exception { - REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(c -> c.sync().scriptFlush(FlushMode.SYNC)); - REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(c -> c.sync().configResetstat()); - - final ClusterLuaScript script = new ClusterLuaScript(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - "return 2;", - ScriptOutputType.INTEGER); - - for (int i = 0; i < 7; i++) { - final long actual = switch (mode) { - case SYNC -> (long) script.execute(Collections.emptyList(), Collections.emptyList()); - case ASYNC -> - (long) script.executeAsync(Collections.emptyList(), Collections.emptyList()).get(5, TimeUnit.SECONDS); - case REACTIVE -> (long) script.executeReactive(Collections.emptyList(), Collections.emptyList()) - .blockLast(Duration.ofSeconds(5)); - }; - - assertEquals(2L, actual); - } - - final int evalCount = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> { - final String commandStats = connection.sync().info("commandstats"); - - // We're looking for (and parsing) a line in the command stats that looks like: - // - // ``` - // cmdstat_eval:calls=1,usec=44,usec_per_call=44.00 - // ``` - return Arrays.stream(commandStats.split("\\n")) - .filter(line -> line.startsWith("cmdstat_eval:")) - .map(String::trim) - .map(evalLine -> Arrays.stream(evalLine.substring(evalLine.indexOf(':') + 1).split(",")) - .filter(pair -> pair.startsWith("calls=")) - .map(callsPair -> Integer.parseInt(callsPair.substring(callsPair.indexOf('=') + 1))) - .findFirst() - .orElse(0)) - .findFirst() - .orElse(0); - }); - - assertEquals(1, evalCount); - } - - private enum ExecuteMode { - SYNC, - ASYNC, - REACTIVE - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubConnectionTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubConnectionTest.java deleted file mode 100644 index a2fb5b7ae..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubConnectionTest.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.github.resilience4j.circuitbreaker.CallNotPermittedException; -import io.github.resilience4j.circuitbreaker.CircuitBreaker; -import io.github.resilience4j.retry.Retry; -import io.lettuce.core.RedisCommandTimeoutException; -import io.lettuce.core.RedisException; -import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection; -import io.lettuce.core.cluster.pubsub.api.sync.RedisClusterPubSubCommands; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; - -class FaultTolerantPubSubConnectionTest { - - private RedisClusterPubSubCommands pubSubCommands; - private FaultTolerantPubSubConnection faultTolerantPubSubConnection; - - @SuppressWarnings("unchecked") - @BeforeEach - public void setUp() { - final StatefulRedisClusterPubSubConnection pubSubConnection = mock( - StatefulRedisClusterPubSubConnection.class); - - pubSubCommands = mock(RedisClusterPubSubCommands.class); - - when(pubSubConnection.sync()).thenReturn(pubSubCommands); - - final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration(); - breakerConfiguration.setFailureRateThreshold(100); - breakerConfiguration.setSlidingWindowSize(1); - breakerConfiguration.setSlidingWindowMinimumNumberOfCalls(1); - breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE); - - final RetryConfiguration retryConfiguration = new RetryConfiguration(); - retryConfiguration.setMaxAttempts(3); - retryConfiguration.setWaitDuration(0); - - final CircuitBreaker circuitBreaker = CircuitBreaker.of("test", breakerConfiguration.toCircuitBreakerConfig()); - final Retry retry = Retry.of("test", retryConfiguration.toRetryConfig()); - - faultTolerantPubSubConnection = new FaultTolerantPubSubConnection<>("test", pubSubConnection, circuitBreaker, - retry); - } - - @Test - void testBreaker() { - when(pubSubCommands.get(anyString())) - .thenReturn("value") - .thenThrow(new RuntimeException("Badness has ensued.")); - - assertEquals("value", faultTolerantPubSubConnection.withPubSubConnection(connection -> connection.sync().get("key"))); - - assertThrows(RedisException.class, - () -> faultTolerantPubSubConnection.withPubSubConnection(connection -> connection.sync().get("OH NO"))); - - final RedisException redisException = assertThrows(RedisException.class, - () -> faultTolerantPubSubConnection.withPubSubConnection(connection -> connection.sync().get("OH NO"))); - - assertTrue(redisException.getCause() instanceof CallNotPermittedException); - } - - @Test - void testRetry() { - when(pubSubCommands.get(anyString())) - .thenThrow(new RedisCommandTimeoutException()) - .thenThrow(new RedisCommandTimeoutException()) - .thenReturn("value"); - - assertEquals("value", faultTolerantPubSubConnection.withPubSubConnection(connection -> connection.sync().get("key"))); - - when(pubSubCommands.get(anyString())) - .thenThrow(new RedisCommandTimeoutException()) - .thenThrow(new RedisCommandTimeoutException()) - .thenThrow(new RedisCommandTimeoutException()) - .thenReturn("value"); - - assertThrows(RedisCommandTimeoutException.class, () -> faultTolerantPubSubConnection.withPubSubConnection(connection -> connection.sync().get("key"))); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisClusterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisClusterTest.java deleted file mode 100644 index 922b433f5..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisClusterTest.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.github.resilience4j.circuitbreaker.CallNotPermittedException; -import io.lettuce.core.RedisCommandTimeoutException; -import io.lettuce.core.RedisException; -import io.lettuce.core.cluster.RedisClusterClient; -import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection; -import io.lettuce.core.event.EventBus; -import io.lettuce.core.resource.ClientResources; -import java.time.Duration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; -import reactor.core.publisher.Flux; - -class FaultTolerantRedisClusterTest { - - private RedisAdvancedClusterCommands clusterCommands; - private FaultTolerantRedisCluster faultTolerantCluster; - - @SuppressWarnings("unchecked") - @BeforeEach - public void setUp() { - final RedisClusterClient clusterClient = mock(RedisClusterClient.class); - final StatefulRedisClusterConnection clusterConnection = mock(StatefulRedisClusterConnection.class); - final StatefulRedisClusterPubSubConnection pubSubConnection = mock(StatefulRedisClusterPubSubConnection.class); - final ClientResources clientResources = mock(ClientResources.class); - final EventBus eventBus = mock(EventBus.class); - - clusterCommands = mock(RedisAdvancedClusterCommands.class); - - when(clusterClient.connect()).thenReturn(clusterConnection); - when(clusterClient.connectPubSub()).thenReturn(pubSubConnection); - when(clusterClient.getResources()).thenReturn(clientResources); - when(clusterConnection.sync()).thenReturn(clusterCommands); - when(clientResources.eventBus()).thenReturn(eventBus); - when(eventBus.get()).thenReturn(mock(Flux.class)); - - final CircuitBreakerConfiguration breakerConfiguration = new CircuitBreakerConfiguration(); - breakerConfiguration.setFailureRateThreshold(100); - breakerConfiguration.setSlidingWindowSize(1); - breakerConfiguration.setSlidingWindowMinimumNumberOfCalls(1); - breakerConfiguration.setWaitDurationInOpenStateInSeconds(Integer.MAX_VALUE); - - final RetryConfiguration retryConfiguration = new RetryConfiguration(); - retryConfiguration.setMaxAttempts(3); - retryConfiguration.setWaitDuration(0); - - faultTolerantCluster = new FaultTolerantRedisCluster("test", clusterClient, Duration.ofSeconds(2), - breakerConfiguration, retryConfiguration); - } - - @Test - void testBreaker() { - when(clusterCommands.get(anyString())) - .thenReturn("value") - .thenThrow(new RuntimeException("Badness has ensued.")); - - assertEquals("value", faultTolerantCluster.withCluster(connection -> connection.sync().get("key"))); - - assertThrows(RedisException.class, - () -> faultTolerantCluster.withCluster(connection -> connection.sync().get("OH NO"))); - - final RedisException redisException = assertThrows(RedisException.class, - () -> faultTolerantCluster.withCluster(connection -> connection.sync().get("OH NO"))); - - assertTrue(redisException.getCause() instanceof CallNotPermittedException); - } - - @Test - void testRetry() { - when(clusterCommands.get(anyString())) - .thenThrow(new RedisCommandTimeoutException()) - .thenThrow(new RedisCommandTimeoutException()) - .thenReturn("value"); - - assertEquals("value", faultTolerantCluster.withCluster(connection -> connection.sync().get("key"))); - - when(clusterCommands.get(anyString())) - .thenThrow(new RedisCommandTimeoutException()) - .thenThrow(new RedisCommandTimeoutException()) - .thenThrow(new RedisCommandTimeoutException()) - .thenReturn("value"); - - assertThrows(RedisCommandTimeoutException.class, () -> faultTolerantCluster.withCluster(connection -> connection.sync().get("key"))); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/redis/RedisClusterExtension.java b/service/src/test/java/org/whispersystems/textsecuregcm/redis/RedisClusterExtension.java deleted file mode 100644 index 96cde43c8..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/redis/RedisClusterExtension.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.redis; - -import static org.junit.jupiter.api.Assumptions.assumeFalse; - -import io.lettuce.core.RedisClient; -import io.lettuce.core.RedisException; -import io.lettuce.core.RedisURI; -import io.lettuce.core.api.StatefulRedisConnection; -import io.lettuce.core.api.sync.RedisCommands; -import io.lettuce.core.cluster.RedisClusterClient; -import io.lettuce.core.cluster.SlotHash; -import java.io.File; -import java.io.IOException; -import java.net.ServerSocket; -import java.time.Duration; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; -import org.junit.jupiter.api.extension.AfterAllCallback; -import org.junit.jupiter.api.extension.AfterEachCallback; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; -import org.whispersystems.textsecuregcm.util.RedisClusterUtil; -import redis.embedded.RedisServer; - -public class RedisClusterExtension implements BeforeAllCallback, BeforeEachCallback, AfterAllCallback, AfterEachCallback { - - public static RedisClusterExtensionBuilder builder() { - return new RedisClusterExtensionBuilder(); - } - - - @Override - public void afterAll(final ExtensionContext context) throws Exception { - for (final RedisServer node : clusterNodes) { - node.stop(); - } - } - - @Override - public void afterEach(final ExtensionContext context) throws Exception { - redisCluster.shutdown(); - } - - @Override - public void beforeAll(final ExtensionContext context) throws Exception { - assumeFalse(System.getProperty("os.name").equalsIgnoreCase("windows")); - - clusterNodes = new RedisServer[NODE_COUNT]; - - for (int i = 0; i < NODE_COUNT; i++) { - clusterNodes[i] = buildClusterNode(getNextRedisClusterPort()); - clusterNodes[i].start(); - } - - assembleCluster(clusterNodes); - } - - @Override - public void beforeEach(final ExtensionContext context) throws Exception { - final List urls = Arrays.stream(clusterNodes) - .map(node -> String.format("redis://127.0.0.1:%d", node.ports().get(0))) - .collect(Collectors.toList()); - - redisCluster = new FaultTolerantRedisCluster("test-cluster", - RedisClusterClient.create(urls.stream().map(RedisURI::create).collect(Collectors.toList())), - Duration.ofSeconds(2), - new CircuitBreakerConfiguration(), - new RetryConfiguration()); - - redisCluster.useCluster(connection -> { - boolean setAll = false; - - final String[] keys = new String[NODE_COUNT]; - - for (int i = 0; i < keys.length; i++) { - keys[i] = RedisClusterUtil.getMinimalHashTag(i * SlotHash.SLOT_COUNT / keys.length); - } - - while (!setAll) { - try { - for (final String key : keys) { - connection.sync().set(key, "warmup"); - } - - setAll = true; - } catch (final RedisException ignored) { - // Cluster isn't ready; wait and retry. - try { - Thread.sleep(500); - } catch (final InterruptedException ignored2) { - } - } - } - }); - - redisCluster.useCluster(connection -> connection.sync().flushall()); - } - - private static final int NODE_COUNT = 2; - - private static RedisServer[] clusterNodes; - - private FaultTolerantRedisCluster redisCluster; - - public FaultTolerantRedisCluster getRedisCluster() { - return redisCluster; - } - - private static RedisServer buildClusterNode(final int port) throws IOException { - final File clusterConfigFile = File.createTempFile("redis", ".conf"); - clusterConfigFile.deleteOnExit(); - - return RedisServer.builder() - .setting("cluster-enabled yes") - .setting("cluster-config-file " + clusterConfigFile.getAbsolutePath()) - .setting("cluster-node-timeout 5000") - .setting("appendonly no") - .setting("save \"\"") - .setting("dir " + System.getProperty("java.io.tmpdir")) - .port(port) - .build(); - } - - private static void assembleCluster(final RedisServer... nodes) throws InterruptedException { - final RedisClient meetClient = RedisClient.create(RedisURI.create("127.0.0.1", nodes[0].ports().get(0))); - - try { - final StatefulRedisConnection connection = meetClient.connect(); - final RedisCommands commands = connection.sync(); - - for (int i = 1; i < nodes.length; i++) { - commands.clusterMeet("127.0.0.1", nodes[i].ports().get(0)); - } - } finally { - meetClient.shutdown(); - } - - final int slotsPerNode = SlotHash.SLOT_COUNT / nodes.length; - - for (int i = 0; i < nodes.length; i++) { - final int startInclusive = i * slotsPerNode; - final int endExclusive = i == nodes.length - 1 ? SlotHash.SLOT_COUNT : (i + 1) * slotsPerNode; - - final RedisClient assignSlotClient = RedisClient.create(RedisURI.create("127.0.0.1", nodes[i].ports().get(0))); - - try (final StatefulRedisConnection assignSlotConnection = assignSlotClient.connect()) { - final int[] slots = new int[endExclusive - startInclusive]; - - for (int s = startInclusive; s < endExclusive; s++) { - slots[s - startInclusive] = s; - } - - assignSlotConnection.sync().clusterAddSlots(slots); - } finally { - assignSlotClient.shutdown(); - } - } - - final RedisClient waitClient = RedisClient.create(RedisURI.create("127.0.0.1", nodes[0].ports().get(0))); - - try (final StatefulRedisConnection connection = waitClient.connect()) { - // CLUSTER INFO gives us a big blob of key-value pairs, but the one we're interested in is `cluster_state`. - // According to https://redis.io/commands/cluster-info, `cluster_state:ok` means that the node is ready to - // receive queries, all slots are assigned, and a majority of master nodes are reachable. - - final int sleepMillis = 500; - int tries = 0; - while (!connection.sync().clusterInfo().contains("cluster_state:ok")) { - Thread.sleep(sleepMillis); - tries++; - - if (tries == 20) { - throw new RuntimeException( - String.format("Timeout: Redis not ready after waiting %d milliseconds", tries * sleepMillis)); - } - } - } finally { - waitClient.shutdown(); - } - } - - public static int getNextRedisClusterPort() throws IOException { - final int MAX_ITERATIONS = 11_000; - int port; - for (int i = 0; i < MAX_ITERATIONS; i++) { - try (ServerSocket socket = new ServerSocket(0)) { - socket.setReuseAddress(false); - port = socket.getLocalPort(); - } - if (port < 55535) { - return port; - } - } - throw new IOException("Couldn't find an open port below 55,535 in " + MAX_ITERATIONS + " tries"); - } - - public static class RedisClusterExtensionBuilder { - - private RedisClusterExtensionBuilder() { - - } - - public RedisClusterExtension build() { - return new RedisClusterExtension(); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClientTest.java deleted file mode 100644 index aaa6df37f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/securebackup/SecureBackupClientTest.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.securebackup; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.delete; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import java.security.cert.CertificateException; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureBackupServiceConfiguration; - -class SecureBackupClientTest { - - private UUID accountUuid; - private ExternalServiceCredentialsGenerator credentialsGenerator; - private ExecutorService httpExecutor; - - private SecureBackupClient secureStorageClient; - - @RegisterExtension - private final WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig().dynamicPort().dynamicHttpsPort()) - .build(); - - @BeforeEach - void setUp() throws CertificateException { - accountUuid = UUID.randomUUID(); - credentialsGenerator = mock(ExternalServiceCredentialsGenerator.class); - httpExecutor = Executors.newSingleThreadExecutor(); - - final SecureBackupServiceConfiguration config = new SecureBackupServiceConfiguration(); - config.setUri("http://localhost:" + wireMock.getPort()); - - // This is a randomly-generated, throwaway certificate that's not actually connected to anything - config.setBackupCaCertificates(List.of(""" - -----BEGIN CERTIFICATE----- - MIICZDCCAc2gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBPMQswCQYDVQQGEwJ1czEL - MAkGA1UECAwCVVMxHjAcBgNVBAoMFVNpZ25hbCBNZXNzZW5nZXIsIExMQzETMBEG - A1UEAwwKc2lnbmFsLm9yZzAeFw0yMDEyMjMyMjQ3NTlaFw0zMDEyMjEyMjQ3NTla - ME8xCzAJBgNVBAYTAnVzMQswCQYDVQQIDAJVUzEeMBwGA1UECgwVU2lnbmFsIE1l - c3NlbmdlciwgTExDMRMwEQYDVQQDDApzaWduYWwub3JnMIGfMA0GCSqGSIb3DQEB - AQUAA4GNADCBiQKBgQCfSLcZNHYqbxSsgWp4JvbPRHjQTrlsrKrgD2q7f/OY6O3Y - /X0QNcNSOJpliN8rmzwslfsrXHO3q1diGRw4xHogUJZ/7NQrHiP/zhN0VTDh49pD - ZpjXVyUbayLS/6qM5arKxBspzEFBb5v8cF6bPr76SO/rpGXiI0j6yJKX6fRiKwID - AQABo1AwTjAdBgNVHQ4EFgQU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwHwYDVR0jBBgw - FoAU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B - AQ0FAAOBgQB+5d5+NtzLILfrc9QmJdIO1YeDP64JmFwTER0kEUouRsb9UwknVWZa - y7MTM4NoBV1k0zb5LAk89SIDPr/maW5AsLtEomzjnEiomjoMBUdNe3YCgQReoLnr - R/QaUNbrCjTGYfBsjGbIzmkWPUyTec2ZdRyJ8JiVl386+6CZkxnndQ== - -----END CERTIFICATE----- - """, - """ - -----BEGIN CERTIFICATE----- - MIIEpDCCAowCCQC43PUTWSADVjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls - b2NhbGhvc3QwHhcNMjIxMDE3MjA0NTM0WhcNMjMxMDE3MjA0NTM0WjAUMRIwEAYD - VQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDV - x1cdEd2ffQTlTXWRiCHGcrlYf4RJnctt9sw/BuHWTLXBu5LhyJSGn5LRszO/NCXK - Z/cmGR7pLj366RtiwL+Qo3nhvDCK7T9xZeNIusM6XMcMK9D/DGCYPqtjQz8NXd9V - ajBBe6nwTDTa+oqX8Mt89foWNkg5Il/lY62u9Dr18LRZ2W9zzYi3Q9/K0CbIX6pM - yVlPIO5rITOR2IsbeyqsO9jufgX5lP4ZKLLBAP1b7usjC4YdvWacjQg/rK5aay1x - jC2HCDgo/4N30QVXzSA9nFfSe6AE/xkStK4819JqOkY5JsJCbef1P3hOOdSLEjbp - xq3MjOs6G6dOgteaAGs10vx7dHxDWETTIiD7BIZ9zRYgOF5bkCaIUO+JfySE1MHD - KBAFLoRuvmRev5Ln5R0MCHpUMSmMNgJqz+RWZV3g/gpYbuWiHgJOwL1393eK50Bg - W7SXQ8EjJj2yXZSH+1gPzN0DRoJZiaBoTPnCL2qUgvwFpW1PJsM5FDyUJFUoK5kK - HLBBSKAPt6ZlSrUe2nBgJv7EF1GK+fTU08LXgW33OpLceGPa0zTShkukQUMtUtZ8 - GqhO12ohMzEupIu5Xurthq4VVUrzHUdj1ZZRMhAbfLU36sd03MMyL/xBqTN6dzCa - GDGIPGpYjAllZ5xMRt2kZdv+Kr6oo3u2nLUIsqI7KQIDAQABMA0GCSqGSIb3DQEB - CwUAA4ICAQCB5s43YF35ssf5YONW5iAaifGpi1o0866xfeOybtohFGvQ7V2W34i9 - TYBCt8+0hgatMcvZ08f0vqig1i7nrvYcE1hnhL7JNkU8qm0s9ytHZt6j62nB0kd/ - uqE2hOEQalTf/2TGPV0CCgiqLyd8lEUQvQeA38wktwUeZpVnErlzHeMR2CvV3K8R - u4vV6SnBcf+TAt56RKYZkPyvZj5llQPo14Glyoo8qZES7Ky1SHmM0GL+baPRBjRW - 3KgSt98Wyu4yr9qu21JpnbAnLhBfzfSKjSeCRgFElUE1GIaFGRZ7ypA74dUKeLnb - /VUWrszmUhGaEjV9dpI6x6B/kSpQMtIQqBaKRY2ALUeEujS/rURi4iMDwSU+GkSH - cyEvZKS97OA/dWeXfLXdo4beDBRG93bI4rQnDg5+VdlBOkQSLueb8x6/VThMoC5d - vZiotFQHseljQAdTkNa6tBu6c4XDYPCKB3CfkMYOlCfTS7Acn5G6dxTPKBtLGBnL - nQfYyzuwYkN09+2PVzt6auBHr3To7uoclkxX+hxyvPIwIZ0N6b4tQR1FCAkvg29Q - WIOjZOKGW690ESKCKOnFjUHVO0HpuWnT81URTuY62FXsYdVc2wE4v0E04mEbqQ0P - lY6ZKNA81Lm3YADYtObmK1IUrOPo9BeIaPy0UM08SmN880Vunqa91Q== - -----END CERTIFICATE----- - """)); - - secureStorageClient = new SecureBackupClient(credentialsGenerator, httpExecutor, config); - } - - @AfterEach - void tearDown() throws InterruptedException { - httpExecutor.shutdown(); - httpExecutor.awaitTermination(1, TimeUnit.SECONDS); - } - - @Test - void deleteStoredData() { - final String username = RandomStringUtils.randomAlphabetic(16); - final String password = RandomStringUtils.randomAlphanumeric(32); - - when(credentialsGenerator.generateForUuid(accountUuid)).thenReturn(new ExternalServiceCredentials(username, password)); - - wireMock.stubFor(delete(urlEqualTo(SecureBackupClient.DELETE_PATH)) - .withBasicAuth(username, password) - .willReturn(aResponse().withStatus(202))); - - // We're happy as long as this doesn't throw an exception - secureStorageClient.deleteBackups(accountUuid).join(); - } - - @Test - void deleteStoredDataFailure() { - final String username = RandomStringUtils.randomAlphabetic(16); - final String password = RandomStringUtils.randomAlphanumeric(32); - - when(credentialsGenerator.generateForUuid(accountUuid)).thenReturn(new ExternalServiceCredentials(username, password)); - - wireMock.stubFor(delete(urlEqualTo(SecureBackupClient.DELETE_PATH)) - .withBasicAuth(username, password) - .willReturn(aResponse().withStatus(400))); - - final CompletionException completionException = assertThrows(CompletionException.class, () -> secureStorageClient.deleteBackups(accountUuid).join()); - assertTrue(completionException.getCause() instanceof SecureBackupException); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClientTest.java deleted file mode 100644 index 81a6282a9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClientTest.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.securestorage; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.delete; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import java.security.cert.CertificateException; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; -import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; - -class SecureStorageClientTest { - - private UUID accountUuid; - private ExternalServiceCredentialsGenerator credentialsGenerator; - private ExecutorService httpExecutor; - - private SecureStorageClient secureStorageClient; - - @RegisterExtension - private final WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig().dynamicPort().dynamicHttpsPort()) - .build(); - - @BeforeEach - void setUp() throws CertificateException { - accountUuid = UUID.randomUUID(); - credentialsGenerator = mock(ExternalServiceCredentialsGenerator.class); - httpExecutor = Executors.newSingleThreadExecutor(); - - final SecureStorageServiceConfiguration config = new SecureStorageServiceConfiguration( - "not_used", - "http://localhost:" + wireMock.getPort(), - List.of(""" - -----BEGIN CERTIFICATE----- - MIICZDCCAc2gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBPMQswCQYDVQQGEwJ1czEL - MAkGA1UECAwCVVMxHjAcBgNVBAoMFVNpZ25hbCBNZXNzZW5nZXIsIExMQzETMBEG - A1UEAwwKc2lnbmFsLm9yZzAeFw0yMDEyMjMyMjQ3NTlaFw0zMDEyMjEyMjQ3NTla - ME8xCzAJBgNVBAYTAnVzMQswCQYDVQQIDAJVUzEeMBwGA1UECgwVU2lnbmFsIE1l - c3NlbmdlciwgTExDMRMwEQYDVQQDDApzaWduYWwub3JnMIGfMA0GCSqGSIb3DQEB - AQUAA4GNADCBiQKBgQCfSLcZNHYqbxSsgWp4JvbPRHjQTrlsrKrgD2q7f/OY6O3Y - /X0QNcNSOJpliN8rmzwslfsrXHO3q1diGRw4xHogUJZ/7NQrHiP/zhN0VTDh49pD - ZpjXVyUbayLS/6qM5arKxBspzEFBb5v8cF6bPr76SO/rpGXiI0j6yJKX6fRiKwID - AQABo1AwTjAdBgNVHQ4EFgQU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwHwYDVR0jBBgw - FoAU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B - AQ0FAAOBgQB+5d5+NtzLILfrc9QmJdIO1YeDP64JmFwTER0kEUouRsb9UwknVWZa - y7MTM4NoBV1k0zb5LAk89SIDPr/maW5AsLtEomzjnEiomjoMBUdNe3YCgQReoLnr - R/QaUNbrCjTGYfBsjGbIzmkWPUyTec2ZdRyJ8JiVl386+6CZkxnndQ== - -----END CERTIFICATE----- - """, - """ - -----BEGIN CERTIFICATE----- - MIIEpDCCAowCCQC43PUTWSADVjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls - b2NhbGhvc3QwHhcNMjIxMDE3MjA0NTM0WhcNMjMxMDE3MjA0NTM0WjAUMRIwEAYD - VQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDV - x1cdEd2ffQTlTXWRiCHGcrlYf4RJnctt9sw/BuHWTLXBu5LhyJSGn5LRszO/NCXK - Z/cmGR7pLj366RtiwL+Qo3nhvDCK7T9xZeNIusM6XMcMK9D/DGCYPqtjQz8NXd9V - ajBBe6nwTDTa+oqX8Mt89foWNkg5Il/lY62u9Dr18LRZ2W9zzYi3Q9/K0CbIX6pM - yVlPIO5rITOR2IsbeyqsO9jufgX5lP4ZKLLBAP1b7usjC4YdvWacjQg/rK5aay1x - jC2HCDgo/4N30QVXzSA9nFfSe6AE/xkStK4819JqOkY5JsJCbef1P3hOOdSLEjbp - xq3MjOs6G6dOgteaAGs10vx7dHxDWETTIiD7BIZ9zRYgOF5bkCaIUO+JfySE1MHD - KBAFLoRuvmRev5Ln5R0MCHpUMSmMNgJqz+RWZV3g/gpYbuWiHgJOwL1393eK50Bg - W7SXQ8EjJj2yXZSH+1gPzN0DRoJZiaBoTPnCL2qUgvwFpW1PJsM5FDyUJFUoK5kK - HLBBSKAPt6ZlSrUe2nBgJv7EF1GK+fTU08LXgW33OpLceGPa0zTShkukQUMtUtZ8 - GqhO12ohMzEupIu5Xurthq4VVUrzHUdj1ZZRMhAbfLU36sd03MMyL/xBqTN6dzCa - GDGIPGpYjAllZ5xMRt2kZdv+Kr6oo3u2nLUIsqI7KQIDAQABMA0GCSqGSIb3DQEB - CwUAA4ICAQCB5s43YF35ssf5YONW5iAaifGpi1o0866xfeOybtohFGvQ7V2W34i9 - TYBCt8+0hgatMcvZ08f0vqig1i7nrvYcE1hnhL7JNkU8qm0s9ytHZt6j62nB0kd/ - uqE2hOEQalTf/2TGPV0CCgiqLyd8lEUQvQeA38wktwUeZpVnErlzHeMR2CvV3K8R - u4vV6SnBcf+TAt56RKYZkPyvZj5llQPo14Glyoo8qZES7Ky1SHmM0GL+baPRBjRW - 3KgSt98Wyu4yr9qu21JpnbAnLhBfzfSKjSeCRgFElUE1GIaFGRZ7ypA74dUKeLnb - /VUWrszmUhGaEjV9dpI6x6B/kSpQMtIQqBaKRY2ALUeEujS/rURi4iMDwSU+GkSH - cyEvZKS97OA/dWeXfLXdo4beDBRG93bI4rQnDg5+VdlBOkQSLueb8x6/VThMoC5d - vZiotFQHseljQAdTkNa6tBu6c4XDYPCKB3CfkMYOlCfTS7Acn5G6dxTPKBtLGBnL - nQfYyzuwYkN09+2PVzt6auBHr3To7uoclkxX+hxyvPIwIZ0N6b4tQR1FCAkvg29Q - WIOjZOKGW690ESKCKOnFjUHVO0HpuWnT81URTuY62FXsYdVc2wE4v0E04mEbqQ0P - lY6ZKNA81Lm3YADYtObmK1IUrOPo9BeIaPy0UM08SmN880Vunqa91Q== - -----END CERTIFICATE----- - """), - new CircuitBreakerConfiguration(), - new RetryConfiguration()); - - secureStorageClient = new SecureStorageClient(credentialsGenerator, httpExecutor, config); - } - - @AfterEach - void tearDown() throws InterruptedException { - httpExecutor.shutdown(); - httpExecutor.awaitTermination(1, TimeUnit.SECONDS); - } - - @Test - void deleteStoredData() { - final String username = RandomStringUtils.randomAlphabetic(16); - final String password = RandomStringUtils.randomAlphanumeric(32); - - when(credentialsGenerator.generateForUuid(accountUuid)).thenReturn(new ExternalServiceCredentials(username, password)); - - wireMock.stubFor(delete(urlEqualTo(SecureStorageClient.DELETE_PATH)) - .withBasicAuth(username, password) - .willReturn(aResponse().withStatus(202))); - - // We're happy as long as this doesn't throw an exception - secureStorageClient.deleteStoredData(accountUuid).join(); - } - - @Test - void deleteStoredDataFailure() { - final String username = RandomStringUtils.randomAlphabetic(16); - final String password = RandomStringUtils.randomAlphanumeric(32); - - when(credentialsGenerator.generateForUuid(accountUuid)).thenReturn(new ExternalServiceCredentials(username, password)); - - wireMock.stubFor(delete(urlEqualTo(SecureStorageClient.DELETE_PATH)) - .withBasicAuth(username, password) - .willReturn(aResponse().withStatus(400))); - - final CompletionException completionException = assertThrows(CompletionException.class, () -> secureStorageClient.deleteStoredData(accountUuid).join()); - assertTrue(completionException.getCause() instanceof SecureStorageException); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/sqs/DirectoryQueueTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/sqs/DirectoryQueueTest.java deleted file mode 100644 index 5e1a1fd1f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/sqs/DirectoryQueueTest.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.sqs; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.storage.Account; -import software.amazon.awssdk.services.sqs.SqsAsyncClient; -import software.amazon.awssdk.services.sqs.model.MessageAttributeValue; -import software.amazon.awssdk.services.sqs.model.SendMessageRequest; -import software.amazon.awssdk.services.sqs.model.SendMessageResponse; - -public class DirectoryQueueTest { - - private SqsAsyncClient sqsAsyncClient; - - @BeforeEach - void setUp() { - sqsAsyncClient = mock(SqsAsyncClient.class); - - when(sqsAsyncClient.sendMessage(any(SendMessageRequest.class))) - .thenReturn(CompletableFuture.completedFuture(SendMessageResponse.builder().build())); - } - - @ParameterizedTest - @MethodSource("argumentsForTestRefreshRegisteredUser") - void testRefreshRegisteredUser(final boolean shouldBeVisibleInDirectory, final String expectedAction) { - final DirectoryQueue directoryQueue = new DirectoryQueue(List.of("sqs://test"), sqsAsyncClient); - - final Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005556543"); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(account.shouldBeVisibleInDirectory()).thenReturn(shouldBeVisibleInDirectory); - - directoryQueue.refreshAccount(account); - - final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(SendMessageRequest.class); - verify(sqsAsyncClient).sendMessage(requestCaptor.capture()); - - assertEquals(MessageAttributeValue.builder().dataType("String").stringValue(expectedAction).build(), - requestCaptor.getValue().messageAttributes().get("action")); - } - - @SuppressWarnings("unused") - private static Stream argumentsForTestRefreshRegisteredUser() { - return Stream.of( - Arguments.of(true, "add"), - Arguments.of(false, "delete")); - } - - @Test - void testSendMessageMultipleQueues() { - final DirectoryQueue directoryQueue = new DirectoryQueue(List.of("sqs://first", "sqs://second"), sqsAsyncClient); - - final Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005556543"); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(account.shouldBeVisibleInDirectory()).thenReturn(true); - - directoryQueue.refreshAccount(account); - - final ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(SendMessageRequest.class); - verify(sqsAsyncClient, times(2)).sendMessage(requestCaptor.capture()); - - for (final SendMessageRequest sendMessageRequest : requestCaptor.getAllValues()) { - assertEquals(MessageAttributeValue.builder().dataType("String").stringValue("add").build(), - sendMessageRequest.messageAttributes().get("action")); - } - } - - @Test - void testStop() { - final CompletableFuture sendMessageFuture = new CompletableFuture<>(); - when(sqsAsyncClient.sendMessage(any(SendMessageRequest.class))).thenReturn(sendMessageFuture); - - final DirectoryQueue directoryQueue = new DirectoryQueue(List.of("sqs://test"), sqsAsyncClient); - - final Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005556543"); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(account.shouldBeVisibleInDirectory()).thenReturn(true); - - directoryQueue.refreshAccount(account); - - final CompletableFuture stopFuture = CompletableFuture.supplyAsync(() -> { - try { - directoryQueue.stop(); - return true; - } catch (final Exception e) { - return false; - } - }); - - assertThrows(TimeoutException.class, () -> stopFuture.get(1, TimeUnit.SECONDS), - "Directory queue should not finish shutting down until all outstanding requests are resolved"); - - sendMessageFuture.complete(SendMessageResponse.builder().build()); - assertTrue(stopFuture.join()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidatorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidatorTest.java deleted file mode 100644 index 484b05ff6..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidatorTest.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Base64; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Stream; -import org.junit.jupiter.api.function.Executable; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class AccountChangeValidatorTest { - - private static final String ORIGINAL_NUMBER = "+18005551234"; - private static final String CHANGED_NUMBER = "+18005559876"; - - private static final UUID ORIGINAL_PNI = UUID.randomUUID(); - private static final UUID CHANGED_PNI = UUID.randomUUID(); - - private static final String BASE_64_URL_ORIGINAL_USERNAME = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE"; - private static final String BASE_64_URL_CHANGED_USERNAME = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc"; - private static final byte[] ORIGINAL_USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_ORIGINAL_USERNAME); - private static final byte[] CHANGED_USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_CHANGED_USERNAME); - - @ParameterizedTest - @MethodSource - void validateChange(final Account originalAccount, - final Account updatedAccount, - final AccountChangeValidator changeValidator, - final boolean expectChangeAllowed) { - - final Executable applyChange = () -> changeValidator.validateChange(originalAccount, updatedAccount); - - if (expectChangeAllowed) { - assertDoesNotThrow(applyChange); - } else { - assertThrows(AssertionError.class, applyChange); - } - } - - private static Stream validateChange() { - final Account originalAccount = mock(Account.class); - when(originalAccount.getNumber()).thenReturn(ORIGINAL_NUMBER); - when(originalAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI); - when(originalAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH)); - - final Account unchangedAccount = mock(Account.class); - when(unchangedAccount.getNumber()).thenReturn(ORIGINAL_NUMBER); - when(unchangedAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI); - when(unchangedAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH)); - - final Account changedNumberAccount = mock(Account.class); - when(changedNumberAccount.getNumber()).thenReturn(CHANGED_NUMBER); - when(changedNumberAccount.getPhoneNumberIdentifier()).thenReturn(CHANGED_PNI); - when(changedNumberAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH)); - - final Account changedUsernameAccount = mock(Account.class); - when(changedUsernameAccount.getNumber()).thenReturn(ORIGINAL_NUMBER); - when(changedUsernameAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI); - when(changedUsernameAccount.getUsernameHash()).thenReturn(Optional.of(CHANGED_USERNAME_HASH)); - - return Stream.of( - Arguments.of(originalAccount, unchangedAccount, AccountChangeValidator.GENERAL_CHANGE_VALIDATOR, true), - Arguments.of(originalAccount, unchangedAccount, AccountChangeValidator.NUMBER_CHANGE_VALIDATOR, true), - Arguments.of(originalAccount, unchangedAccount, AccountChangeValidator.USERNAME_CHANGE_VALIDATOR, true), - - Arguments.of(originalAccount, changedNumberAccount, AccountChangeValidator.GENERAL_CHANGE_VALIDATOR, false), - Arguments.of(originalAccount, changedNumberAccount, AccountChangeValidator.NUMBER_CHANGE_VALIDATOR, true), - Arguments.of(originalAccount, changedNumberAccount, AccountChangeValidator.USERNAME_CHANGE_VALIDATOR, false), - - Arguments.of(originalAccount, changedUsernameAccount, AccountChangeValidator.GENERAL_CHANGE_VALIDATOR, false), - Arguments.of(originalAccount, changedUsernameAccount, AccountChangeValidator.NUMBER_CHANGE_VALIDATOR, false), - Arguments.of(originalAccount, changedUsernameAccount, AccountChangeValidator.USERNAME_CHANGE_VALIDATOR, true) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCleanerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCleanerTest.java deleted file mode 100644 index 0100983f6..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCleanerTest.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.storage.AccountsManager.DeletionReason; - -class AccountCleanerTest { - - private final AccountsManager accountsManager = mock(AccountsManager.class); - - private final Account deletedDisabledAccount = mock(Account.class); - private final Account undeletedDisabledAccount = mock(Account.class); - private final Account undeletedEnabledAccount = mock(Account.class); - - private final Device deletedDisabledDevice = mock(Device.class ); - private final Device undeletedDisabledDevice = mock(Device.class ); - private final Device undeletedEnabledDevice = mock(Device.class ); - - private ExecutorService deletionExecutor; - - - @BeforeEach - void setup() { - when(deletedDisabledDevice.isEnabled()).thenReturn(false); - when(deletedDisabledDevice.getGcmId()).thenReturn(null); - when(deletedDisabledDevice.getApnId()).thenReturn(null); - when(deletedDisabledDevice.getVoipApnId()).thenReturn(null); - when(deletedDisabledDevice.getFetchesMessages()).thenReturn(false); - when(deletedDisabledAccount.isEnabled()).thenReturn(false); - when(deletedDisabledAccount.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1000)); - when(deletedDisabledAccount.getMasterDevice()).thenReturn(Optional.of(deletedDisabledDevice)); - when(deletedDisabledAccount.getNumber()).thenReturn("+14151231234"); - when(deletedDisabledAccount.getUuid()).thenReturn(UUID.randomUUID()); - - when(undeletedDisabledDevice.isEnabled()).thenReturn(false); - when(undeletedDisabledDevice.getGcmId()).thenReturn("foo"); - when(undeletedDisabledAccount.isEnabled()).thenReturn(false); - when(undeletedDisabledAccount.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(366)); - when(undeletedDisabledAccount.getMasterDevice()).thenReturn(Optional.of(undeletedDisabledDevice)); - when(undeletedDisabledAccount.getNumber()).thenReturn("+14152222222"); - when(undeletedDisabledAccount.getUuid()).thenReturn(UUID.randomUUID()); - - when(undeletedEnabledDevice.isEnabled()).thenReturn(true); - when(undeletedEnabledDevice.getApnId()).thenReturn("bar"); - when(undeletedEnabledAccount.isEnabled()).thenReturn(true); - when(undeletedEnabledAccount.getMasterDevice()).thenReturn(Optional.of(undeletedEnabledDevice)); - when(undeletedEnabledAccount.getNumber()).thenReturn("+14153333333"); - when(undeletedEnabledAccount.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(364)); - when(undeletedEnabledAccount.getUuid()).thenReturn(UUID.randomUUID()); - - deletionExecutor = Executors.newFixedThreadPool(2); - } - - @AfterEach - void tearDown() throws InterruptedException { - deletionExecutor.shutdown(); - deletionExecutor.awaitTermination(2, TimeUnit.SECONDS); - } - - @Test - void testAccounts() throws AccountDatabaseCrawlerRestartException, InterruptedException { - AccountCleaner accountCleaner = new AccountCleaner(accountsManager, deletionExecutor); - accountCleaner.onCrawlStart(); - accountCleaner.timeAndProcessCrawlChunk(Optional.empty(), Arrays.asList(deletedDisabledAccount, undeletedDisabledAccount, undeletedEnabledAccount)); - accountCleaner.onCrawlEnd(Optional.empty()); - - verify(accountsManager).delete(deletedDisabledAccount, DeletionReason.EXPIRED); - verify(accountsManager).delete(undeletedDisabledAccount, DeletionReason.EXPIRED); - verify(accountsManager, never()).delete(eq(undeletedEnabledAccount), any()); - - verifyNoMoreInteractions(accountsManager); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerIntegrationTest.java deleted file mode 100644 index 2dcef4636..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountDatabaseCrawlerIntegrationTest.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; - -class AccountDatabaseCrawlerIntegrationTest { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private static final UUID FIRST_UUID = UUID.fromString("82339e80-81cd-48e2-9ed2-ccd5dd262ad9"); - private static final UUID SECOND_UUID = UUID.fromString("cc705c84-33cf-456b-8239-a6a34e2f561a"); - - private Account firstAccount; - private Account secondAccount; - - private AccountsManager accountsManager; - private AccountDatabaseCrawlerListener listener; - - private AccountDatabaseCrawler accountDatabaseCrawler; - - private static final int CHUNK_SIZE = 1; - private static final long CHUNK_INTERVAL_MS = 0; - - @BeforeEach - void setUp() throws Exception { - - firstAccount = mock(Account.class); - secondAccount = mock(Account.class); - - accountsManager = mock(AccountsManager.class); - listener = mock(AccountDatabaseCrawlerListener.class); - - when(firstAccount.getUuid()).thenReturn(FIRST_UUID); - when(secondAccount.getUuid()).thenReturn(SECOND_UUID); - - when(accountsManager.getAllFromDynamo(CHUNK_SIZE)).thenReturn( - new AccountCrawlChunk(List.of(firstAccount), FIRST_UUID)); - when(accountsManager.getAllFromDynamo(any(UUID.class), eq(CHUNK_SIZE))) - .thenReturn(new AccountCrawlChunk(List.of(secondAccount), SECOND_UUID)) - .thenReturn(new AccountCrawlChunk(Collections.emptyList(), null)); - - final AccountDatabaseCrawlerCache crawlerCache = new AccountDatabaseCrawlerCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(), "test"); - accountDatabaseCrawler = new AccountDatabaseCrawler("test", accountsManager, crawlerCache, List.of(listener), CHUNK_SIZE, - CHUNK_INTERVAL_MS); - } - - @Test - void testCrawlUninterrupted() throws AccountDatabaseCrawlerRestartException { - assertFalse(accountDatabaseCrawler.doPeriodicWork()); - assertFalse(accountDatabaseCrawler.doPeriodicWork()); - assertFalse(accountDatabaseCrawler.doPeriodicWork()); - - verify(accountsManager).getAllFromDynamo(CHUNK_SIZE); - verify(accountsManager).getAllFromDynamo(FIRST_UUID, CHUNK_SIZE); - verify(accountsManager).getAllFromDynamo(SECOND_UUID, CHUNK_SIZE); - - verify(listener).onCrawlStart(); - verify(listener).timeAndProcessCrawlChunk(Optional.empty(), List.of(firstAccount)); - verify(listener).timeAndProcessCrawlChunk(Optional.of(FIRST_UUID), List.of(secondAccount)); - verify(listener).onCrawlEnd(Optional.of(SECOND_UUID)); - } - - @Test - void testCrawlWithReset() throws AccountDatabaseCrawlerRestartException { - doThrow(new AccountDatabaseCrawlerRestartException("OH NO")).doNothing() - .when(listener).timeAndProcessCrawlChunk(Optional.empty(), List.of(firstAccount)); - - assertFalse(accountDatabaseCrawler.doPeriodicWork()); - assertFalse(accountDatabaseCrawler.doPeriodicWork()); - assertFalse(accountDatabaseCrawler.doPeriodicWork()); - assertFalse(accountDatabaseCrawler.doPeriodicWork()); - - verify(accountsManager, times(2)).getAllFromDynamo(CHUNK_SIZE); - verify(accountsManager).getAllFromDynamo(FIRST_UUID, CHUNK_SIZE); - verify(accountsManager).getAllFromDynamo(SECOND_UUID, CHUNK_SIZE); - - verify(listener, times(2)).onCrawlStart(); - verify(listener, times(2)).timeAndProcessCrawlChunk(Optional.empty(), List.of(firstAccount)); - verify(listener).timeAndProcessCrawlChunk(Optional.of(FIRST_UUID), List.of(secondAccount)); - verify(listener).onCrawlEnd(Optional.of(SECOND_UUID)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java deleted file mode 100644 index 169f69d45..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java +++ /dev/null @@ -1,353 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.Clock; -import java.util.ArrayList; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; -import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.Projection; -import software.amazon.awssdk.services.dynamodb.model.ProjectionType; -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class AccountsManagerChangeNumberIntegrationTest { - - private static final String ACCOUNTS_TABLE_NAME = "accounts_test"; - private static final String NUMBERS_TABLE_NAME = "numbers_test"; - private static final String PNI_ASSIGNMENT_TABLE_NAME = "pni_assignment_test"; - private static final String USERNAMES_TABLE_NAME = "usernames_test"; - private static final String PNI_TABLE_NAME = "pni_test"; - private static final String NEEDS_RECONCILIATION_INDEX_NAME = "needs_reconciliation_test"; - private static final String DELETED_ACCOUNTS_LOCK_TABLE_NAME = "deleted_accounts_lock_test"; - private static final int SCAN_PAGE_SIZE = 1; - - @RegisterExtension - static DynamoDbExtension ACCOUNTS_DYNAMO_EXTENSION = DynamoDbExtension.builder() - .tableName(ACCOUNTS_TABLE_NAME) - .hashKey(Accounts.KEY_ACCOUNT_UUID) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(Accounts.KEY_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - @RegisterExtension - static DynamoDbExtension DELETED_ACCOUNTS_DYNAMO_EXTENSION = DynamoDbExtension.builder() - .tableName("deleted_accounts_test") - .hashKey(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S).build()) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) - .attributeType(ScalarAttributeType.N) - .build()) - .globalSecondaryIndex(GlobalSecondaryIndex.builder() - .indexName(NEEDS_RECONCILIATION_INDEX_NAME) - .keySchema(KeySchemaElement.builder().attributeName(DeletedAccounts.KEY_ACCOUNT_E164).keyType(KeyType.HASH).build(), - KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION).keyType(KeyType.RANGE).build()) - .projection(Projection.builder().projectionType(ProjectionType.INCLUDE).nonKeyAttributes(DeletedAccounts.ATTR_ACCOUNT_UUID).build()) - .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) - .build()) - .build(); - - @RegisterExtension - static DynamoDbExtension DELETED_ACCOUNTS_LOCK_DYNAMO_EXTENSION = DynamoDbExtension.builder() - .tableName(DELETED_ACCOUNTS_LOCK_TABLE_NAME) - .hashKey(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S).build()) - .build(); - - @RegisterExtension - static DynamoDbExtension PNI_DYNAMO_EXTENSION = DynamoDbExtension.builder() - .tableName(PNI_TABLE_NAME) - .hashKey(PhoneNumberIdentifiers.KEY_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(PhoneNumberIdentifiers.KEY_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .build(); - - @RegisterExtension - static RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private ClientPresenceManager clientPresenceManager; - private DeletedAccounts deletedAccounts; - - private AccountsManager accountsManager; - - @BeforeEach - void setup() throws InterruptedException { - - { - CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder() - .tableName(NUMBERS_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createNumbersTableRequest); - } - - { - CreateTableRequest createPhoneNumberIdentifierTableRequest = CreateTableRequest.builder() - .tableName(PNI_ASSIGNMENT_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest); - } - - { - @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = - mock(DynamicConfigurationManager.class); - - DynamicConfiguration dynamicConfiguration = new DynamicConfiguration(); - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - - final Accounts accounts = new Accounts( - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient(), - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbAsyncClient(), - ACCOUNTS_DYNAMO_EXTENSION.getTableName(), - NUMBERS_TABLE_NAME, - PNI_ASSIGNMENT_TABLE_NAME, - USERNAMES_TABLE_NAME, - SCAN_PAGE_SIZE); - - deletedAccounts = new DeletedAccounts(DELETED_ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient(), - DELETED_ACCOUNTS_DYNAMO_EXTENSION.getTableName(), - NEEDS_RECONCILIATION_INDEX_NAME); - - final DeletedAccountsManager deletedAccountsManager = new DeletedAccountsManager(deletedAccounts, - DELETED_ACCOUNTS_LOCK_DYNAMO_EXTENSION.getLegacyDynamoClient(), - DELETED_ACCOUNTS_LOCK_DYNAMO_EXTENSION.getTableName()); - - final SecureStorageClient secureStorageClient = mock(SecureStorageClient.class); - when(secureStorageClient.deleteStoredData(any())).thenReturn(CompletableFuture.completedFuture(null)); - - final SecureBackupClient secureBackupClient = mock(SecureBackupClient.class); - when(secureBackupClient.deleteBackups(any())).thenReturn(CompletableFuture.completedFuture(null)); - - clientPresenceManager = mock(ClientPresenceManager.class); - - final PhoneNumberIdentifiers phoneNumberIdentifiers = - new PhoneNumberIdentifiers(PNI_DYNAMO_EXTENSION.getDynamoDbClient(), PNI_TABLE_NAME); - - accountsManager = new AccountsManager( - accounts, - phoneNumberIdentifiers, - CACHE_CLUSTER_EXTENSION.getRedisCluster(), - deletedAccountsManager, - mock(DirectoryQueue.class), - mock(Keys.class), - mock(MessagesManager.class), - mock(ProfilesManager.class), - mock(StoredVerificationCodeManager.class), - secureStorageClient, - secureBackupClient, - clientPresenceManager, - mock(ExperimentEnrollmentManager.class), - mock(RegistrationRecoveryPasswordsManager.class), - mock(Clock.class)); - } - } - - @Test - void testChangeNumber() throws InterruptedException, MismatchedDevicesException { - final String originalNumber = "+18005551111"; - final String secondNumber = "+18005552222"; - - final Account account = accountsManager.create(originalNumber, "password", null, new AccountAttributes(), new ArrayList<>()); - final UUID originalUuid = account.getUuid(); - final UUID originalPni = account.getPhoneNumberIdentifier(); - - accountsManager.changeNumber(account, secondNumber, null, null, null); - - assertTrue(accountsManager.getByE164(originalNumber).isEmpty()); - - assertTrue(accountsManager.getByE164(secondNumber).isPresent()); - assertEquals(originalUuid, accountsManager.getByE164(secondNumber).map(Account::getUuid).orElseThrow()); - assertNotEquals(originalPni, accountsManager.getByE164(secondNumber).map(Account::getPhoneNumberIdentifier).orElseThrow()); - - assertEquals(secondNumber, accountsManager.getByAccountIdentifier(originalUuid).map(Account::getNumber).orElseThrow()); - - assertEquals(Optional.empty(), deletedAccounts.findUuid(originalNumber)); - assertEquals(Optional.empty(), deletedAccounts.findUuid(secondNumber)); - } - - @Test - void testChangeNumberWithPniExtensions() throws InterruptedException, MismatchedDevicesException { - final String originalNumber = "+18005551111"; - final String secondNumber = "+18005552222"; - final int rotatedPniRegistrationId = 17; - final SignedPreKey rotatedSignedPreKey = new SignedPreKey(1, "test", "test"); - - final AccountAttributes accountAttributes = new AccountAttributes(true, rotatedPniRegistrationId + 1, "test", null, true, new Device.DeviceCapabilities()); - final Account account = accountsManager.create(originalNumber, "password", null, accountAttributes, new ArrayList<>()); - account.getMasterDevice().orElseThrow().setSignedPreKey(new SignedPreKey()); - - final UUID originalUuid = account.getUuid(); - final UUID originalPni = account.getPhoneNumberIdentifier(); - - final String pniIdentityKey = "changed-pni-identity-key"; - final Map preKeys = Map.of(Device.MASTER_ID, rotatedSignedPreKey); - final Map registrationIds = Map.of(Device.MASTER_ID, rotatedPniRegistrationId); - - final Account updatedAccount = accountsManager.changeNumber(account, secondNumber, pniIdentityKey, preKeys, registrationIds); - - assertTrue(accountsManager.getByE164(originalNumber).isEmpty()); - - assertTrue(accountsManager.getByE164(secondNumber).isPresent()); - assertEquals(originalUuid, accountsManager.getByE164(secondNumber).map(Account::getUuid).orElseThrow()); - assertNotEquals(originalPni, accountsManager.getByE164(secondNumber).map(Account::getPhoneNumberIdentifier).orElseThrow()); - - assertEquals(secondNumber, accountsManager.getByAccountIdentifier(originalUuid).map(Account::getNumber).orElseThrow()); - - assertEquals(Optional.empty(), deletedAccounts.findUuid(originalNumber)); - assertEquals(Optional.empty(), deletedAccounts.findUuid(secondNumber)); - - assertEquals(pniIdentityKey, updatedAccount.getPhoneNumberIdentityKey()); - - assertEquals(OptionalInt.of(rotatedPniRegistrationId), - updatedAccount.getMasterDevice().orElseThrow().getPhoneNumberIdentityRegistrationId()); - - assertEquals(rotatedSignedPreKey, updatedAccount.getMasterDevice().orElseThrow().getPhoneNumberIdentitySignedPreKey()); - } - - @Test - void testChangeNumberReturnToOriginal() throws InterruptedException, MismatchedDevicesException { - final String originalNumber = "+18005551111"; - final String secondNumber = "+18005552222"; - - Account account = accountsManager.create(originalNumber, "password", null, new AccountAttributes(), new ArrayList<>()); - final UUID originalUuid = account.getUuid(); - final UUID originalPni = account.getPhoneNumberIdentifier(); - - account = accountsManager.changeNumber(account, secondNumber, null, null, null); - accountsManager.changeNumber(account, originalNumber, null, null, null); - - assertTrue(accountsManager.getByE164(originalNumber).isPresent()); - assertEquals(originalUuid, accountsManager.getByE164(originalNumber).map(Account::getUuid).orElseThrow()); - assertEquals(originalPni, accountsManager.getByE164(originalNumber).map(Account::getPhoneNumberIdentifier).orElseThrow()); - - assertTrue(accountsManager.getByE164(secondNumber).isEmpty()); - - assertEquals(originalNumber, accountsManager.getByAccountIdentifier(originalUuid).map(Account::getNumber).orElseThrow()); - - assertEquals(Optional.empty(), deletedAccounts.findUuid(originalNumber)); - assertEquals(Optional.empty(), deletedAccounts.findUuid(secondNumber)); - } - - @Test - void testChangeNumberContested() throws InterruptedException, MismatchedDevicesException { - final String originalNumber = "+18005551111"; - final String secondNumber = "+18005552222"; - - final Account account = accountsManager.create(originalNumber, "password", null, new AccountAttributes(), new ArrayList<>()); - final UUID originalUuid = account.getUuid(); - - final Account existingAccount = accountsManager.create(secondNumber, "password", null, new AccountAttributes(), new ArrayList<>()); - final UUID existingAccountUuid = existingAccount.getUuid(); - - accountsManager.changeNumber(account, secondNumber, null, null, null); - - assertTrue(accountsManager.getByE164(originalNumber).isEmpty()); - - assertTrue(accountsManager.getByE164(secondNumber).isPresent()); - assertEquals(Optional.of(originalUuid), accountsManager.getByE164(secondNumber).map(Account::getUuid)); - - assertEquals(secondNumber, accountsManager.getByAccountIdentifier(originalUuid).map(Account::getNumber).orElseThrow()); - - verify(clientPresenceManager).disconnectPresence(existingAccountUuid, Device.MASTER_ID); - - assertEquals(Optional.of(existingAccountUuid), deletedAccounts.findUuid(originalNumber)); - assertEquals(Optional.empty(), deletedAccounts.findUuid(secondNumber)); - - accountsManager.changeNumber(accountsManager.getByAccountIdentifier(originalUuid).orElseThrow(), originalNumber, null, null, null); - - final Account existingAccount2 = accountsManager.create(secondNumber, "password", null, new AccountAttributes(), - new ArrayList<>()); - - assertEquals(existingAccountUuid, existingAccount2.getUuid()); - } - - @Test - void testChangeNumberChaining() throws InterruptedException, MismatchedDevicesException { - final String originalNumber = "+18005551111"; - final String secondNumber = "+18005552222"; - - final Account account = accountsManager.create(originalNumber, "password", null, new AccountAttributes(), new ArrayList<>()); - final UUID originalUuid = account.getUuid(); - final UUID originalPni = account.getPhoneNumberIdentifier(); - - final Account existingAccount = accountsManager.create(secondNumber, "password", null, new AccountAttributes(), new ArrayList<>()); - final UUID existingAccountUuid = existingAccount.getUuid(); - - final Account changedNumberAccount = accountsManager.changeNumber(account, secondNumber, null, null, null); - final UUID secondPni = changedNumberAccount.getPhoneNumberIdentifier(); - - final Account reRegisteredAccount = accountsManager.create(originalNumber, "password", null, new AccountAttributes(), new ArrayList<>()); - - assertEquals(existingAccountUuid, reRegisteredAccount.getUuid()); - assertEquals(originalPni, reRegisteredAccount.getPhoneNumberIdentifier()); - - assertEquals(Optional.empty(), deletedAccounts.findUuid(originalNumber)); - assertEquals(Optional.empty(), deletedAccounts.findUuid(secondNumber)); - - final Account changedNumberReRegisteredAccount = accountsManager.changeNumber(reRegisteredAccount, secondNumber, null, null, null); - - assertEquals(Optional.of(originalUuid), deletedAccounts.findUuid(originalNumber)); - assertEquals(Optional.empty(), deletedAccounts.findUuid(secondNumber)); - assertEquals(secondPni, changedNumberReRegisteredAccount.getPhoneNumberIdentifier()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java deleted file mode 100644 index e1ff343b9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import java.io.IOException; -import java.time.Clock; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.LinkedBlockingDeque; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.tests.util.DevicesHelper; -import org.whispersystems.textsecuregcm.tests.util.JsonHelpers; -import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper; -import org.whispersystems.textsecuregcm.util.Pair; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class AccountsManagerConcurrentModificationIntegrationTest { - - private static final String ACCOUNTS_TABLE_NAME = "accounts_test"; - private static final String NUMBERS_TABLE_NAME = "numbers_test"; - private static final String PNI_TABLE_NAME = "pni_test"; - private static final String USERNAMES_TABLE_NAME = "usernames_test"; - - private static final int SCAN_PAGE_SIZE = 1; - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(ACCOUNTS_TABLE_NAME) - .hashKey(Accounts.KEY_ACCOUNT_UUID) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(Accounts.KEY_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - private Accounts accounts; - - private AccountsManager accountsManager; - - private RedisAdvancedClusterCommands commands; - - private Executor mutationExecutor = new ThreadPoolExecutor(20, 20, 5, TimeUnit.SECONDS, new LinkedBlockingDeque<>(20)); - - @BeforeEach - void setup() throws InterruptedException { - - { - CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder() - .tableName(NUMBERS_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - dynamoDbExtension.getDynamoDbClient().createTable(createNumbersTableRequest); - } - - { - CreateTableRequest createPhoneNumberIdentifierTableRequest = CreateTableRequest.builder() - .tableName(PNI_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - dynamoDbExtension.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest); - } - - @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = - mock(DynamicConfigurationManager.class); - when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration()); - - accounts = new Accounts( - dynamoDbExtension.getDynamoDbClient(), - dynamoDbExtension.getDynamoDbAsyncClient(), - dynamoDbExtension.getTableName(), - NUMBERS_TABLE_NAME, - PNI_TABLE_NAME, - USERNAMES_TABLE_NAME, - SCAN_PAGE_SIZE); - - { - //noinspection unchecked - commands = mock(RedisAdvancedClusterCommands.class); - - final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class); - - doAnswer(invocation -> { - //noinspection unchecked - invocation.getArgument(1, Consumer.class).accept(Optional.empty()); - return null; - }).when(deletedAccountsManager).lockAndTake(anyString(), any()); - - final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class); - when(phoneNumberIdentifiers.getPhoneNumberIdentifier(anyString())) - .thenAnswer((Answer) invocation -> UUID.randomUUID()); - - accountsManager = new AccountsManager( - accounts, - phoneNumberIdentifiers, - RedisClusterHelper.builder().stringCommands(commands).build(), - deletedAccountsManager, - mock(DirectoryQueue.class), - mock(Keys.class), - mock(MessagesManager.class), - mock(ProfilesManager.class), - mock(StoredVerificationCodeManager.class), - mock(SecureStorageClient.class), - mock(SecureBackupClient.class), - mock(ClientPresenceManager.class), - mock(ExperimentEnrollmentManager.class), - mock(RegistrationRecoveryPasswordsManager.class), - mock(Clock.class) - ); - } - } - - @Test - void testConcurrentUpdate() throws IOException, InterruptedException { - - final UUID uuid; - { - final Account account = accountsManager.update( - accountsManager.create("+14155551212", "password", null, new AccountAttributes(), new ArrayList<>()), - a -> { - a.setUnidentifiedAccessKey(new byte[16]); - - final Random random = new Random(); - final SignedPreKey signedPreKey = new SignedPreKey(random.nextInt(), "testPublicKey-" + random.nextInt(), - "testSignature-" + random.nextInt()); - - a.removeDevice(1); - a.addDevice(DevicesHelper.createDevice(1)); - }); - - uuid = account.getUuid(); - } - - final boolean discoverableByPhoneNumber = false; - final String currentProfileVersion = "cpv"; - final String identityKey = "ikey"; - final byte[] unidentifiedAccessKey = new byte[]{1}; - final String pin = "1234"; - final String registrationLock = "reglock"; - final SaltedTokenHash credentials = SaltedTokenHash.generateFor(registrationLock); - final boolean unrestrictedUnidentifiedAccess = true; - final long lastSeen = Instant.now().getEpochSecond(); - - CompletableFuture.allOf( - modifyAccount(uuid, account -> account.setDiscoverableByPhoneNumber(discoverableByPhoneNumber)), - modifyAccount(uuid, account -> account.setCurrentProfileVersion(currentProfileVersion)), - modifyAccount(uuid, account -> account.setIdentityKey(identityKey)), - modifyAccount(uuid, account -> account.setUnidentifiedAccessKey(unidentifiedAccessKey)), - modifyAccount(uuid, account -> account.setRegistrationLock(credentials.hash(), credentials.salt())), - modifyAccount(uuid, account -> account.setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess)), - modifyDevice(uuid, Device.MASTER_ID, device -> device.setLastSeen(lastSeen)), - modifyDevice(uuid, Device.MASTER_ID, device -> device.setName("deviceName")) - ).join(); - - final Account managerAccount = accountsManager.getByAccountIdentifier(uuid).orElseThrow(); - final Account dynamoAccount = accounts.getByAccountIdentifier(uuid).orElseThrow(); - - final Account redisAccount = getLastAccountFromRedisMock(commands); - - Stream.of( - new Pair<>("manager", managerAccount), - new Pair<>("dynamo", dynamoAccount), - new Pair<>("redis", redisAccount) - ).forEach(pair -> - verifyAccount(pair.first(), pair.second(), discoverableByPhoneNumber, - currentProfileVersion, identityKey, unidentifiedAccessKey, pin, registrationLock, - unrestrictedUnidentifiedAccess, lastSeen)); - } - - private Account getLastAccountFromRedisMock(RedisAdvancedClusterCommands commands) throws IOException { - ArgumentCaptor redisSetArgumentCapture = ArgumentCaptor.forClass(String.class); - - verify(commands, atLeast(20)).setex(anyString(), anyLong(), redisSetArgumentCapture.capture()); - - return JsonHelpers.fromJson(redisSetArgumentCapture.getValue(), Account.class); - } - - private void verifyAccount(final String name, final Account account, final boolean discoverableByPhoneNumber, final String currentProfileVersion, final String identityKey, final byte[] unidentifiedAccessKey, final String pin, final String clientRegistrationLock, final boolean unrestrictedUnidentifiedAccess, final long lastSeen) { - - assertAll(name, - () -> assertEquals(discoverableByPhoneNumber, account.isDiscoverableByPhoneNumber()), - () -> assertEquals(currentProfileVersion, account.getCurrentProfileVersion().orElseThrow()), - () -> assertEquals(identityKey, account.getIdentityKey()), - () -> assertArrayEquals(unidentifiedAccessKey, account.getUnidentifiedAccessKey().orElseThrow()), - () -> assertTrue(account.getRegistrationLock().verify(clientRegistrationLock)), - () -> assertEquals(unrestrictedUnidentifiedAccess, account.isUnrestrictedUnidentifiedAccess()) - ); - } - - private CompletableFuture modifyAccount(final UUID uuid, final Consumer accountMutation) { - - return CompletableFuture.runAsync(() -> { - final Account account = accountsManager.getByAccountIdentifier(uuid).orElseThrow(); - accountsManager.update(account, accountMutation); - }, mutationExecutor); - } - - private CompletableFuture modifyDevice(final UUID uuid, final long deviceId, final Consumer deviceMutation) { - - return CompletableFuture.runAsync(() -> { - final Account account = accountsManager.getByAccountIdentifier(uuid).orElseThrow(); - accountsManager.updateDevice(account, deviceId, deviceMutation); - }, mutationExecutor); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java deleted file mode 100644 index 263121ca2..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java +++ /dev/null @@ -1,833 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import io.lettuce.core.RedisException; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import java.time.Clock; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.function.BiFunction; -import java.util.function.Consumer; -import java.util.stream.Stream; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper; - -class AccountsManagerTest { - private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE"; - private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc"; - private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1); - private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2); - - private Accounts accounts; - private DeletedAccountsManager deletedAccountsManager; - private DirectoryQueue directoryQueue; - private Keys keys; - private MessagesManager messagesManager; - private ProfilesManager profilesManager; - private ExperimentEnrollmentManager enrollmentManager; - - private Map phoneNumberIdentifiersByE164; - - private RedisAdvancedClusterCommands commands; - private AccountsManager accountsManager; - - private static final Answer ACCOUNT_UPDATE_ANSWER = (answer) -> { - // it is implicit in the update() contract is that a successful call will - // result in an incremented version - final Account updatedAccount = answer.getArgument(0, Account.class); - updatedAccount.setVersion(updatedAccount.getVersion() + 1); - return null; - }; - - @BeforeEach - void setup() throws InterruptedException { - accounts = mock(Accounts.class); - deletedAccountsManager = mock(DeletedAccountsManager.class); - directoryQueue = mock(DirectoryQueue.class); - keys = mock(Keys.class); - messagesManager = mock(MessagesManager.class); - profilesManager = mock(ProfilesManager.class); - - //noinspection unchecked - commands = mock(RedisAdvancedClusterCommands.class); - - doAnswer((Answer) invocation -> { - final Account account = invocation.getArgument(0, Account.class); - final String number = invocation.getArgument(1, String.class); - final UUID phoneNumberIdentifier = invocation.getArgument(2, UUID.class); - - account.setNumber(number, phoneNumberIdentifier); - - return null; - }).when(accounts).changeNumber(any(), anyString(), any()); - - doAnswer(invocation -> { - //noinspection unchecked - invocation.getArgument(1, Consumer.class).accept(Optional.empty()); - return null; - }).when(deletedAccountsManager).lockAndTake(anyString(), any()); - - final SecureStorageClient storageClient = mock(SecureStorageClient.class); - when(storageClient.deleteStoredData(any())).thenReturn(CompletableFuture.completedFuture(null)); - - final SecureBackupClient backupClient = mock(SecureBackupClient.class); - when(backupClient.deleteBackups(any())).thenReturn(CompletableFuture.completedFuture(null)); - - final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class); - phoneNumberIdentifiersByE164 = new HashMap<>(); - - when(phoneNumberIdentifiers.getPhoneNumberIdentifier(anyString())).thenAnswer((Answer) invocation -> { - final String number = invocation.getArgument(0, String.class); - return phoneNumberIdentifiersByE164.computeIfAbsent(number, n -> UUID.randomUUID()); - }); - - @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = - mock(DynamicConfigurationManager.class); - - final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - - enrollmentManager = mock(ExperimentEnrollmentManager.class); - when(enrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME))).thenReturn(true); - when(accounts.usernameHashAvailable(any())).thenReturn(true); - - accountsManager = new AccountsManager( - accounts, - phoneNumberIdentifiers, - RedisClusterHelper.builder().stringCommands(commands).build(), - deletedAccountsManager, - directoryQueue, - keys, - messagesManager, - profilesManager, - mock(StoredVerificationCodeManager.class), - storageClient, - backupClient, - mock(ClientPresenceManager.class), - enrollmentManager, - mock(RegistrationRecoveryPasswordsManager.class), - mock(Clock.class)); - } - - @Test - void testGetAccountByNumberInCache() { - UUID uuid = UUID.randomUUID(); - - when(commands.get(eq("AccountMap::+14152222222"))).thenReturn(uuid.toString()); - when(commands.get(eq("Account3::" + uuid))).thenReturn( - "{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}"); - - Optional account = accountsManager.getByE164("+14152222222"); - - assertTrue(account.isPresent()); - assertEquals(account.get().getNumber(), "+14152222222"); - assertEquals(UUID.fromString("de24dc73-fbd8-41be-a7d5-764c70d9da7e"), account.get().getPhoneNumberIdentifier()); - - verify(commands, times(1)).get(eq("AccountMap::+14152222222")); - verify(commands, times(1)).get(eq("Account3::" + uuid)); - verifyNoMoreInteractions(commands); - - verifyNoInteractions(accounts); - } - - @Test - void testGetAccountByUuidInCache() { - UUID uuid = UUID.randomUUID(); - - when(commands.get(eq("Account3::" + uuid))).thenReturn( - "{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}"); - - Optional account = accountsManager.getByAccountIdentifier(uuid); - - assertTrue(account.isPresent()); - assertEquals(account.get().getNumber(), "+14152222222"); - assertEquals(account.get().getUuid(), uuid); - assertEquals(UUID.fromString("de24dc73-fbd8-41be-a7d5-764c70d9da7e"), account.get().getPhoneNumberIdentifier()); - - verify(commands, times(1)).get(eq("Account3::" + uuid)); - verifyNoMoreInteractions(commands); - - verifyNoInteractions(accounts); - } - - @Test - void testGetByPniInCache() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - - when(commands.get(eq("AccountMap::" + pni))).thenReturn(uuid.toString()); - when(commands.get(eq("Account3::" + uuid))).thenReturn( - "{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"}"); - - Optional account = accountsManager.getByPhoneNumberIdentifier(pni); - - assertTrue(account.isPresent()); - assertEquals(account.get().getNumber(), "+14152222222"); - assertEquals(UUID.fromString("de24dc73-fbd8-41be-a7d5-764c70d9da7e"), account.get().getPhoneNumberIdentifier()); - - verify(commands).get(eq("AccountMap::" + pni)); - verify(commands).get(eq("Account3::" + uuid)); - verifyNoMoreInteractions(commands); - - verifyNoInteractions(accounts); - } - - @Test - void testGetByUsernameHashInCache() { - UUID uuid = UUID.randomUUID(); - when(commands.get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1))).thenReturn(uuid.toString()); - when(commands.get(eq("Account3::" + uuid))).thenReturn( - String.format("{\"number\": \"+14152222222\", \"pni\": \"de24dc73-fbd8-41be-a7d5-764c70d9da7e\", \"usernameHash\": \"%s\"}", - BASE_64_URL_USERNAME_HASH_1)); - - Optional account = accountsManager.getByUsernameHash(USERNAME_HASH_1); - - assertTrue(account.isPresent()); - assertEquals(account.get().getNumber(), "+14152222222"); - assertEquals(UUID.fromString("de24dc73-fbd8-41be-a7d5-764c70d9da7e"), account.get().getPhoneNumberIdentifier()); - assertArrayEquals(USERNAME_HASH_1, account.get().getUsernameHash().get()); - - verify(commands).get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1)); - verify(commands).get(eq("Account3::" + uuid)); - verifyNoMoreInteractions(commands); - - verifyNoInteractions(accounts); - } - - @Test - void testGetAccountByNumberNotInCache() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]); - - when(commands.get(eq("AccountMap::+14152222222"))).thenReturn(null); - when(accounts.getByE164(eq("+14152222222"))).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByE164("+14152222222"); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands, times(1)).get(eq("AccountMap::+14152222222")); - verify(commands, times(1)).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("AccountMap::" + pni), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts, times(1)).getByE164(eq("+14152222222")); - verifyNoMoreInteractions(accounts); - } - - @Test - void testGetAccountByUuidNotInCache() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]); - - when(commands.get(eq("Account3::" + uuid))).thenReturn(null); - when(accounts.getByAccountIdentifier(eq(uuid))).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByAccountIdentifier(uuid); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands, times(1)).get(eq("Account3::" + uuid)); - verify(commands, times(1)).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("AccountMap::" + pni), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts, times(1)).getByAccountIdentifier(eq(uuid)); - verifyNoMoreInteractions(accounts); - } - - @Test - void testGetAccountByPniNotInCache() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]); - - when(commands.get(eq("AccountMap::" + pni))).thenReturn(null); - when(accounts.getByPhoneNumberIdentifier(pni)).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByPhoneNumberIdentifier(pni); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands).get(eq("AccountMap::" + pni)); - verify(commands).setex(eq("AccountMap::" + pni), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts).getByPhoneNumberIdentifier(pni); - verifyNoMoreInteractions(accounts); - } - - @Test - void testGetAccountByUsernameHashNotInCache() { - UUID uuid = UUID.randomUUID(); - - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]); - account.setUsernameHash(USERNAME_HASH_1); - - when(commands.get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1))).thenReturn(null); - when(accounts.getByUsernameHash(USERNAME_HASH_1)).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByUsernameHash(USERNAME_HASH_1); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands).get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1)); - verify(commands).setex(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("AccountMap::" + account.getPhoneNumberIdentifier()), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts).getByUsernameHash(USERNAME_HASH_1); - verifyNoMoreInteractions(accounts); - } - - @Test - void testGetAccountByNumberBrokenCache() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]); - - when(commands.get(eq("AccountMap::+14152222222"))).thenThrow(new RedisException("Connection lost!")); - when(accounts.getByE164(eq("+14152222222"))).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByE164("+14152222222"); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands, times(1)).get(eq("AccountMap::+14152222222")); - verify(commands, times(1)).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("AccountMap::" + pni), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts, times(1)).getByE164(eq("+14152222222")); - verifyNoMoreInteractions(accounts); - } - - @Test - void testGetAccountByUuidBrokenCache() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]); - - when(commands.get(eq("Account3::" + uuid))).thenThrow(new RedisException("Connection lost!")); - when(accounts.getByAccountIdentifier(eq(uuid))).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByAccountIdentifier(uuid); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands, times(1)).get(eq("Account3::" + uuid)); - verify(commands, times(1)).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("AccountMap::" + pni), anyLong(), eq(uuid.toString())); - verify(commands, times(1)).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts, times(1)).getByAccountIdentifier(eq(uuid)); - verifyNoMoreInteractions(accounts); - } - - @Test - void testGetAccountByPniBrokenCache() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]); - - when(commands.get(eq("AccountMap::" + pni))).thenThrow(new RedisException("OH NO")); - when(accounts.getByPhoneNumberIdentifier(pni)).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByPhoneNumberIdentifier(pni); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands).get(eq("AccountMap::" + pni)); - verify(commands).setex(eq("AccountMap::" + pni), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts).getByPhoneNumberIdentifier(pni); - verifyNoMoreInteractions(accounts); - } - - @Test - void testGetAccountByUsernameBrokenCache() { - UUID uuid = UUID.randomUUID(); - - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]); - account.setUsernameHash(USERNAME_HASH_1); - - when(commands.get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1))).thenThrow(new RedisException("OH NO")); - when(accounts.getByUsernameHash(USERNAME_HASH_1)).thenReturn(Optional.of(account)); - - Optional retrieved = accountsManager.getByUsernameHash(USERNAME_HASH_1); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), account); - - verify(commands).get(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1)); - verify(commands).setex(eq("UAccountMap::" + BASE_64_URL_USERNAME_HASH_1), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("AccountMap::" + account.getPhoneNumberIdentifier()), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("AccountMap::+14152222222"), anyLong(), eq(uuid.toString())); - verify(commands).setex(eq("Account3::" + uuid), anyLong(), anyString()); - verifyNoMoreInteractions(commands); - - verify(accounts).getByUsernameHash(USERNAME_HASH_1); - verifyNoMoreInteractions(accounts); - } - - @Test - void testUpdate_optimisticLockingFailure() { - UUID uuid = UUID.randomUUID(); - UUID pni = UUID.randomUUID(); - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]); - - when(commands.get(eq("Account3::" + uuid))).thenReturn(null); - - when(accounts.getByAccountIdentifier(uuid)).thenReturn( - Optional.of(AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]))); - doThrow(ContestedOptimisticLockException.class) - .doAnswer(ACCOUNT_UPDATE_ANSWER) - .when(accounts).update(any()); - - when(accounts.getByAccountIdentifier(uuid)).thenReturn( - Optional.of(AccountsHelper.generateTestAccount("+14152222222", uuid, pni, new ArrayList<>(), new byte[16]))); - doThrow(ContestedOptimisticLockException.class) - .doAnswer(ACCOUNT_UPDATE_ANSWER) - .when(accounts).update(any()); - - account = accountsManager.update(account, a -> a.setIdentityKey("identity-key")); - - assertEquals(1, account.getVersion()); - assertEquals("identity-key", account.getIdentityKey()); - - verify(accounts, times(1)).getByAccountIdentifier(uuid); - verify(accounts, times(2)).update(any()); - verifyNoMoreInteractions(accounts); - } - - @Test - void testUpdate_dynamoOptimisticLockingFailureDuringCreate() { - UUID uuid = UUID.randomUUID(); - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]); - - when(commands.get(eq("Account3::" + uuid))).thenReturn(null); - when(accounts.getByAccountIdentifier(uuid)).thenReturn(Optional.empty()) - .thenReturn(Optional.of(account)); - when(accounts.create(any())).thenThrow(ContestedOptimisticLockException.class); - - accountsManager.update(account, a -> { - }); - - verify(accounts, times(1)).update(account); - verifyNoMoreInteractions(accounts); - } - - @Test - void testUpdateDevice() { - final UUID uuid = UUID.randomUUID(); - Account account = AccountsHelper.generateTestAccount("+14152222222", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]); - - when(accounts.getByAccountIdentifier(uuid)).thenReturn( - Optional.of(AccountsHelper.generateTestAccount("+14152222222", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]))); - - assertTrue(account.getDevices().isEmpty()); - - Device enabledDevice = new Device(); - enabledDevice.setFetchesMessages(true); - enabledDevice.setSignedPreKey(new SignedPreKey(1L, "key", "signature")); - enabledDevice.setLastSeen(System.currentTimeMillis()); - final long deviceId = account.getNextDeviceId(); - enabledDevice.setId(deviceId); - account.addDevice(enabledDevice); - - @SuppressWarnings("unchecked") Consumer deviceUpdater = mock(Consumer.class); - @SuppressWarnings("unchecked") Consumer unknownDeviceUpdater = mock(Consumer.class); - - account = accountsManager.updateDevice(account, deviceId, deviceUpdater); - account = accountsManager.updateDevice(account, deviceId, d -> d.setName("deviceName")); - - assertEquals("deviceName", account.getDevice(deviceId).orElseThrow().getName()); - - verify(deviceUpdater, times(1)).accept(any(Device.class)); - - accountsManager.updateDevice(account, account.getNextDeviceId(), unknownDeviceUpdater); - - verify(unknownDeviceUpdater, never()).accept(any(Device.class)); - } - - @Test - void testCreateFreshAccount() throws InterruptedException { - when(accounts.create(any())).thenReturn(true); - - final String e164 = "+18005550123"; - final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, true, null); - accountsManager.create(e164, "password", null, attributes, new ArrayList<>()); - - verify(accounts).create(argThat(account -> e164.equals(account.getNumber()))); - verifyNoInteractions(keys); - verifyNoInteractions(messagesManager); - verifyNoInteractions(profilesManager); - } - - @Test - void testReregisterAccount() throws InterruptedException { - final UUID existingUuid = UUID.randomUUID(); - - when(accounts.create(any())).thenAnswer(invocation -> { - invocation.getArgument(0, Account.class).setUuid(existingUuid); - return false; - }); - - final String e164 = "+18005550123"; - final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, true, null); - accountsManager.create(e164, "password", null, attributes, new ArrayList<>()); - - assertTrue(phoneNumberIdentifiersByE164.containsKey(e164)); - - verify(accounts) - .create(argThat(account -> e164.equals(account.getNumber()) && existingUuid.equals(account.getUuid()))); - - verify(keys).delete(existingUuid); - verify(keys).delete(phoneNumberIdentifiersByE164.get(e164)); - verify(messagesManager).clear(existingUuid); - verify(profilesManager).deleteAll(existingUuid); - } - - @Test - void testCreateAccountRecentlyDeleted() throws InterruptedException { - final UUID recentlyDeletedUuid = UUID.randomUUID(); - - doAnswer(invocation -> { - //noinspection unchecked - invocation.getArgument(1, Consumer.class).accept(Optional.of(recentlyDeletedUuid)); - return null; - }).when(deletedAccountsManager).lockAndTake(anyString(), any()); - - when(accounts.create(any())).thenReturn(true); - - final String e164 = "+18005550123"; - final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, true, null); - accountsManager.create(e164, "password", null, attributes, new ArrayList<>()); - - verify(accounts).create( - argThat(account -> e164.equals(account.getNumber()) && recentlyDeletedUuid.equals(account.getUuid()))); - verifyNoInteractions(keys); - verifyNoInteractions(messagesManager); - verifyNoInteractions(profilesManager); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testCreateWithDiscoverability(final boolean discoverable) throws InterruptedException { - final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, discoverable, null); - final Account account = accountsManager.create("+18005550123", "password", null, attributes, new ArrayList<>()); - - assertEquals(discoverable, account.isDiscoverableByPhoneNumber()); - - if (!discoverable) { - verify(directoryQueue).deleteAccount(account); - } - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testCreateWithStorageCapability(final boolean hasStorage) throws InterruptedException { - final AccountAttributes attributes = new AccountAttributes(false, 0, null, null, true, - new DeviceCapabilities(hasStorage, false, false, false, false, false, false, false, false)); - - final Account account = accountsManager.create("+18005550123", "password", null, attributes, new ArrayList<>()); - - assertEquals(hasStorage, account.isStorageSupported()); - } - - @ParameterizedTest - @MethodSource - void testUpdateDirectoryQueue(final boolean visibleBeforeUpdate, final boolean visibleAfterUpdate, - final boolean expectRefresh) { - final Account account = AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - - // this sets up the appropriate result for Account#shouldBeVisibleInDirectory - final Device device = generateTestDevice(0); - account.addDevice(device); - account.setDiscoverableByPhoneNumber(visibleBeforeUpdate); - - final Account updatedAccount = accountsManager.update(account, - a -> a.setDiscoverableByPhoneNumber(visibleAfterUpdate)); - - verify(directoryQueue, times(expectRefresh ? 1 : 0)).refreshAccount(updatedAccount); - } - - @SuppressWarnings("unused") - private static Stream testUpdateDirectoryQueue() { - return Stream.of( - Arguments.of(false, false, false), - Arguments.of(true, true, false), - Arguments.of(false, true, true), - Arguments.of(true, false, true)); - } - - @ParameterizedTest - @MethodSource - void testUpdateDeviceLastSeen(final boolean expectUpdate, final long initialLastSeen, final long updatedLastSeen) { - final Account account = AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - final Device device = generateTestDevice(initialLastSeen); - account.addDevice(device); - - accountsManager.updateDeviceLastSeen(account, device, updatedLastSeen); - - assertEquals(expectUpdate ? updatedLastSeen : initialLastSeen, device.getLastSeen()); - verify(accounts, expectUpdate ? times(1) : never()).update(account); - } - - @SuppressWarnings("unused") - private static Stream testUpdateDeviceLastSeen() { - return Stream.of( - Arguments.of(true, 1, 2), - Arguments.of(false, 1, 1), - Arguments.of(false, 2, 1) - ); - } - - @Test - void testChangePhoneNumber() throws InterruptedException, MismatchedDevicesException { - doAnswer(invocation -> invocation.getArgument(2, BiFunction.class).apply(Optional.empty(), Optional.empty())) - .when(deletedAccountsManager).lockAndPut(anyString(), anyString(), any()); - - final String originalNumber = "+14152222222"; - final String targetNumber = "+14153333333"; - final UUID uuid = UUID.randomUUID(); - final UUID originalPni = UUID.randomUUID(); - - Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, originalPni, new ArrayList<>(), new byte[16]); - account = accountsManager.changeNumber(account, targetNumber, null, null, null); - - assertEquals(targetNumber, account.getNumber()); - - assertTrue(phoneNumberIdentifiersByE164.containsKey(targetNumber)); - - verify(directoryQueue).changePhoneNumber(argThat(a -> a.getUuid().equals(uuid)), eq(originalNumber), eq(targetNumber)); - verify(keys).delete(originalPni); - verify(keys).delete(phoneNumberIdentifiersByE164.get(targetNumber)); - } - - @Test - void testChangePhoneNumberSameNumber() throws InterruptedException, MismatchedDevicesException { - final String number = "+14152222222"; - - Account account = AccountsHelper.generateTestAccount(number, UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - account = accountsManager.changeNumber(account, number, null, null, null); - - assertEquals(number, account.getNumber()); - verify(deletedAccountsManager, never()).lockAndPut(anyString(), anyString(), any()); - verify(directoryQueue, never()).changePhoneNumber(any(), any(), any()); - verify(keys, never()).delete(any()); - } - - @Test - void testChangePhoneNumberExistingAccount() throws InterruptedException, MismatchedDevicesException { - doAnswer(invocation -> invocation.getArgument(2, BiFunction.class).apply(Optional.empty(), Optional.empty())) - .when(deletedAccountsManager).lockAndPut(anyString(), anyString(), any()); - - final String originalNumber = "+14152222222"; - final String targetNumber = "+14153333333"; - final UUID existingAccountUuid = UUID.randomUUID(); - final UUID uuid = UUID.randomUUID(); - final UUID originalPni = UUID.randomUUID(); - final UUID targetPni = UUID.randomUUID(); - - final Account existingAccount = AccountsHelper.generateTestAccount(targetNumber, existingAccountUuid, targetPni, new ArrayList<>(), new byte[16]); - when(accounts.getByE164(targetNumber)).thenReturn(Optional.of(existingAccount)); - - Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, originalPni, new ArrayList<>(), new byte[16]); - account = accountsManager.changeNumber(account, targetNumber, null, null, null); - - assertEquals(targetNumber, account.getNumber()); - - assertTrue(phoneNumberIdentifiersByE164.containsKey(targetNumber)); - - verify(directoryQueue).changePhoneNumber(argThat(a -> a.getUuid().equals(uuid)), eq(originalNumber), eq(targetNumber)); - verify(directoryQueue).deleteAccount(existingAccount); - verify(keys).delete(originalPni); - verify(keys).delete(targetPni); - } - - @Test - void testChangePhoneNumberViaUpdate() { - final String originalNumber = "+14152222222"; - final String targetNumber = "+14153333333"; - final UUID uuid = UUID.randomUUID(); - - final Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, UUID.randomUUID(), new ArrayList<>(), new byte[16]); - - assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setNumber(targetNumber, UUID.randomUUID()))); - } - - @Test - void testReserveUsernameHash() throws UsernameHashNotAvailableException { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - final List usernameHashes = List.of(new byte[32], new byte[32]); - when(accounts.usernameHashAvailable(any())).thenReturn(true); - accountsManager.reserveUsernameHash(account, usernameHashes); - verify(accounts).reserveUsernameHash(eq(account), eq(new byte[32]), eq(Duration.ofMinutes(5))); - } - - @Test - void testReserveUsernameHashNotAvailable() { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - when(accounts.usernameHashAvailable(any())).thenReturn(false); - - assertThrows(UsernameHashNotAvailableException.class, () -> accountsManager.reserveUsernameHash(account, List.of( - USERNAME_HASH_1, USERNAME_HASH_2))); - } - - @Test - void testReserveUsernameDisabled() { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - when(enrollmentManager.isEnrolled(account.getUuid(), AccountsManager.USERNAME_EXPERIMENT_NAME)).thenReturn(false); - assertThrows(UsernameHashNotAvailableException.class, () -> accountsManager.reserveUsernameHash(account, List.of( - USERNAME_HASH_1))); - } - - @Test - void testConfirmReservedUsernameHash() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - setReservationHash(account, USERNAME_HASH_1); - when(accounts.usernameHashAvailable(eq(Optional.of(account.getUuid())), eq(USERNAME_HASH_1))).thenReturn(true); - accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1); - verify(accounts).confirmUsernameHash(eq(account), eq(USERNAME_HASH_1)); - } - - @Test - void testConfirmReservedHashNameMismatch() { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - setReservationHash(account, USERNAME_HASH_1); - when(accounts.usernameHashAvailable(eq(Optional.of(account.getUuid())), eq(USERNAME_HASH_1))).thenReturn(true); - assertThrows(UsernameReservationNotFoundException.class, - () -> accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_2)); - } - - @Test - void testConfirmReservedLapsed() { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - // hash was reserved, but the reservation lapsed and another account took it - setReservationHash(account, USERNAME_HASH_1); - when(accounts.usernameHashAvailable(eq(Optional.of(account.getUuid())), eq(USERNAME_HASH_1))).thenReturn(false); - assertThrows(UsernameHashNotAvailableException.class, () -> accountsManager.confirmReservedUsernameHash(account, - USERNAME_HASH_1)); - verify(accounts, never()).confirmUsernameHash(any(), any()); - } - - @Test - void testConfirmReservedRetry() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - account.setUsernameHash(USERNAME_HASH_1); - - // reserved username already set, should be treated as a replay - accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1); - verifyNoInteractions(accounts); - } - - @Test - void testConfirmReservedUsernameHashWithNoReservation() { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - new ArrayList<>(), new byte[16]); - assertThrows(UsernameReservationNotFoundException.class, - () -> accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1)); - verify(accounts, never()).confirmUsernameHash(any(), any()); - } - - @Test - void testClearUsernameHash() { - Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - account.setUsernameHash(USERNAME_HASH_1); - accountsManager.clearUsernameHash(account); - verify(accounts).clearUsernameHash(eq(account)); - } - - @Test - void testSetUsernameViaUpdate() { - final Account account = AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[16]); - - assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsernameHash(USERNAME_HASH_1))); - } - - private void setReservationHash(final Account account, final byte[] reservedUsernameHash) { - account.setReservedUsernameHash(reservedUsernameHash); - } - - private static Device generateTestDevice(final long lastSeen) { - final Device device = new Device(); - device.setId(Device.MASTER_ID); - device.setFetchesMessages(true); - device.setSignedPreKey(new SignedPreKey(1, "key", "sig")); - device.setLastSeen(lastSeen); - - return device; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java deleted file mode 100644 index ab41b791b..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.function.Consumer; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.mockito.Mockito; -import org.mockito.invocation.InvocationOnMock; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; -import org.whispersystems.textsecuregcm.securebackup.SecureBackupClient; -import org.whispersystems.textsecuregcm.securestorage.SecureStorageClient; -import org.whispersystems.textsecuregcm.sqs.DirectoryQueue; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -class AccountsManagerUsernameIntegrationTest { - - private static final String ACCOUNTS_TABLE_NAME = "accounts_test"; - private static final String NUMBERS_TABLE_NAME = "numbers_test"; - private static final String PNI_ASSIGNMENT_TABLE_NAME = "pni_assignment_test"; - private static final String USERNAMES_TABLE_NAME = "usernames_test"; - private static final String PNI_TABLE_NAME = "pni_test"; - private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE"; - private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc"; - private static final int SCAN_PAGE_SIZE = 1; - private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1); - private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2); - - @RegisterExtension - static DynamoDbExtension ACCOUNTS_DYNAMO_EXTENSION = DynamoDbExtension.builder() - .tableName(ACCOUNTS_TABLE_NAME) - .hashKey(Accounts.KEY_ACCOUNT_UUID) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(Accounts.KEY_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - @RegisterExtension - static DynamoDbExtension PNI_DYNAMO_EXTENSION = DynamoDbExtension.builder() - .tableName(PNI_TABLE_NAME) - .hashKey(PhoneNumberIdentifiers.KEY_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(PhoneNumberIdentifiers.KEY_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .build(); - - @RegisterExtension - static RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private AccountsManager accountsManager; - private Accounts accounts; - - @BeforeEach - void setup() throws InterruptedException { - CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder() - .tableName(NUMBERS_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createNumbersTableRequest); - CreateTableRequest createUsernamesTableRequest = CreateTableRequest.builder() - .tableName(USERNAMES_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_USERNAME_HASH) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_USERNAME_HASH) - .attributeType(ScalarAttributeType.B) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createUsernamesTableRequest); - CreateTableRequest createPhoneNumberIdentifierTableRequest = CreateTableRequest.builder() - .tableName(PNI_ASSIGNMENT_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest); - buildAccountsManager(1, 2, 10); - } - - private void buildAccountsManager(final int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth) - throws InterruptedException { - @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = - mock(DynamicConfigurationManager.class); - - DynamicConfiguration dynamicConfiguration = new DynamicConfiguration(); - when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration); - - accounts = Mockito.spy(new Accounts( - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient(), - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbAsyncClient(), - ACCOUNTS_DYNAMO_EXTENSION.getTableName(), - NUMBERS_TABLE_NAME, - PNI_ASSIGNMENT_TABLE_NAME, - USERNAMES_TABLE_NAME, - SCAN_PAGE_SIZE)); - - final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class); - doAnswer((final InvocationOnMock invocationOnMock) -> { - @SuppressWarnings("unchecked") - Consumer> consumer = invocationOnMock.getArgument(1, Consumer.class); - consumer.accept(Optional.empty()); - return null; - }).when(deletedAccountsManager).lockAndTake(any(), any()); - - final PhoneNumberIdentifiers phoneNumberIdentifiers = - new PhoneNumberIdentifiers(PNI_DYNAMO_EXTENSION.getDynamoDbClient(), PNI_TABLE_NAME); - - final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class); - when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(AccountsManager.USERNAME_EXPERIMENT_NAME))) - .thenReturn(true); - accountsManager = new AccountsManager( - accounts, - phoneNumberIdentifiers, - CACHE_CLUSTER_EXTENSION.getRedisCluster(), - deletedAccountsManager, - mock(DirectoryQueue.class), - mock(Keys.class), - mock(MessagesManager.class), - mock(ProfilesManager.class), - mock(StoredVerificationCodeManager.class), - mock(SecureStorageClient.class), - mock(SecureBackupClient.class), - mock(ClientPresenceManager.class), - experimentEnrollmentManager, - mock(RegistrationRecoveryPasswordsManager.class), - mock(Clock.class)); - } - - @Test - void testNoUsernames() throws InterruptedException { - Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(), - new ArrayList<>()); - List usernameHashes = List.of(USERNAME_HASH_1, USERNAME_HASH_2); - int i = 0; - for (byte[] hash : usernameHashes) { - final Map item = new HashMap<>(Map.of( - Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()), - Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(hash))); - // half of these are taken usernames, half are only reservations (have a TTL) - if (i % 2 == 0) { - item.put(Accounts.ATTR_TTL, - AttributeValues.fromLong(Instant.now().plus(Duration.ofMinutes(1)).getEpochSecond())); - } - i++; - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder() - .tableName(USERNAMES_TABLE_NAME) - .item(item) - .build()); - } - assertThrows(UsernameHashNotAvailableException.class, () -> {accountsManager.reserveUsernameHash(account, usernameHashes);}); - assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsernameHash()).isEmpty(); - } - - @Test - void testReserveUsernameSnatched() throws InterruptedException, UsernameHashNotAvailableException { - final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(), - new ArrayList<>()); - ArrayList usernameHashes = new ArrayList<>(Arrays.asList(USERNAME_HASH_1, USERNAME_HASH_2)); - for (byte[] hash : usernameHashes) { - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder() - .tableName(USERNAMES_TABLE_NAME) - .item(Map.of( - Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()), - Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(hash))) - .build()); - } - - - byte[] availableHash = new byte[32]; - new SecureRandom().nextBytes(availableHash); - usernameHashes.add(availableHash); - - // first time this is called lie and say the username is available - // this simulates seeing an available username and then it being taken - // by someone before the write - doReturn(true).doCallRealMethod().when(accounts).usernameHashAvailable(any()); - final byte[] username = accountsManager - .reserveUsernameHash(account, usernameHashes) - .reservedUsernameHash(); - - assertArrayEquals(username, availableHash); - - // 1 attempt on first try (returns true), - // 5 more attempts until "availableHash" returns true - verify(accounts, times(4)).usernameHashAvailable(any()); - } - - @Test - public void testReserveConfirmClear() - throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException { - Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(), - new ArrayList<>()); - - // reserve - AccountsManager.UsernameReservation reservation = accountsManager.reserveUsernameHash(account, List.of( - USERNAME_HASH_1)); - assertArrayEquals(reservation.account().getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1); - assertThat(accountsManager.getByUsernameHash(reservation.reservedUsernameHash())).isEmpty(); - - // confirm - account = accountsManager.confirmReservedUsernameHash( - reservation.account(), - reservation.reservedUsernameHash()); - assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1); - assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1).orElseThrow().getUuid()).isEqualTo( - account.getUuid()); - - // clear - account = accountsManager.clearUsernameHash(account); - assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1)).isEmpty(); - assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsernameHash()).isEmpty(); - } - - @Test - public void testReservationLapsed() - throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException { - - final Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(), - new ArrayList<>()); - AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsernameHash(account, List.of( - USERNAME_HASH_1)); - - long past = Instant.now().minus(Duration.ofMinutes(1)).getEpochSecond(); - // force expiration - ACCOUNTS_DYNAMO_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder() - .tableName(USERNAMES_TABLE_NAME) - .key(Map.of(Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(USERNAME_HASH_1))) - .updateExpression("SET #ttl = :ttl") - .expressionAttributeNames(Map.of("#ttl", Accounts.ATTR_TTL)) - .expressionAttributeValues(Map.of(":ttl", AttributeValues.fromLong(past))) - .build()); - - // a different account should be able to reserve it - Account account2 = accountsManager.create("+18005552222", "password", null, new AccountAttributes(), - new ArrayList<>()); - final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsernameHash(account2, List.of( - USERNAME_HASH_1)); - assertArrayEquals(reservation2.reservedUsernameHash(), USERNAME_HASH_1); - - assertThrows(UsernameHashNotAvailableException.class, - () -> accountsManager.confirmReservedUsernameHash(reservation1.account(), USERNAME_HASH_1)); - account2 = accountsManager.confirmReservedUsernameHash(reservation2.account(), USERNAME_HASH_1); - assertEquals(accountsManager.getByUsernameHash(USERNAME_HASH_1).orElseThrow().getUuid(), account2.getUuid()); - assertArrayEquals(account2.getUsernameHash().orElseThrow(), USERNAME_HASH_1); - } - - @Test - void testUsernameSetReserveAnotherClearSetReserved() - throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException { - Account account = accountsManager.create("+18005551111", "password", null, new AccountAttributes(), - new ArrayList<>()); - - // Set username hash - final AccountsManager.UsernameReservation reservation1 = accountsManager.reserveUsernameHash(account, List.of( - USERNAME_HASH_1)); - account = accountsManager.confirmReservedUsernameHash(reservation1.account(), USERNAME_HASH_1); - - // Reserve another hash on the same account - final AccountsManager.UsernameReservation reservation2 = accountsManager.reserveUsernameHash(account, List.of( - USERNAME_HASH_2)); - account = reservation2.account(); - - assertArrayEquals(account.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_2); - assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1); - - // Clear the set username hash but not the reserved one - account = accountsManager.clearUsernameHash(account); - assertThat(account.getReservedUsernameHash()).isPresent(); - assertThat(account.getUsernameHash()).isEmpty(); - - // Confirm second reservation - account = accountsManager.confirmReservedUsernameHash(account, reservation2.reservedUsernameHash()); - assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_2); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java deleted file mode 100644 index 1f3b09039..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java +++ /dev/null @@ -1,990 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.uuid.UUIDComparator; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.DevicesHelper; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.util.TestClock; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; -import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.Put; -import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; -import software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; -import software.amazon.awssdk.services.dynamodb.model.TransactWriteItem; -import software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest; -import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; -import software.amazon.awssdk.services.dynamodb.model.TransactionConflictException; -import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; - -class AccountsTest { - - private static final String ACCOUNTS_TABLE_NAME = "accounts_test"; - private static final String NUMBER_CONSTRAINT_TABLE_NAME = "numbers_test"; - private static final String PNI_CONSTRAINT_TABLE_NAME = "pni_test"; - private static final String USERNAME_CONSTRAINT_TABLE_NAME = "username_test"; - private static final String BASE_64_URL_USERNAME_HASH_1 = "9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE"; - private static final String BASE_64_URL_USERNAME_HASH_2 = "NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc"; - private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1); - private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2); - - private static final int SCAN_PAGE_SIZE = 1; - - - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(ACCOUNTS_TABLE_NAME) - .hashKey(Accounts.KEY_ACCOUNT_UUID) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(Accounts.KEY_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - private final TestClock clock = TestClock.pinned(Instant.EPOCH); - private DynamicConfigurationManager mockDynamicConfigManager; - private Accounts accounts; - - @BeforeEach - void setupAccountsDao() { - CreateTableRequest createNumbersTableRequest = CreateTableRequest.builder() - .tableName(NUMBER_CONSTRAINT_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - dynamoDbExtension.getDynamoDbClient().createTable(createNumbersTableRequest); - - CreateTableRequest createPhoneNumberIdentifierTableRequest = CreateTableRequest.builder() - .tableName(PNI_CONSTRAINT_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_PNI_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - dynamoDbExtension.getDynamoDbClient().createTable(createPhoneNumberIdentifierTableRequest); - - CreateTableRequest createUsernamesTableRequest = CreateTableRequest.builder() - .tableName(USERNAME_CONSTRAINT_TABLE_NAME) - .keySchema(KeySchemaElement.builder() - .attributeName(Accounts.ATTR_USERNAME_HASH) - .keyType(KeyType.HASH) - .build()) - .attributeDefinitions(AttributeDefinition.builder() - .attributeName(Accounts.ATTR_USERNAME_HASH) - .attributeType(ScalarAttributeType.B) - .build()) - .provisionedThroughput(DynamoDbExtension.DEFAULT_PROVISIONED_THROUGHPUT) - .build(); - - dynamoDbExtension.getDynamoDbClient().createTable(createUsernamesTableRequest); - - @SuppressWarnings("unchecked") DynamicConfigurationManager m = mock(DynamicConfigurationManager.class); - mockDynamicConfigManager = m; - - when(mockDynamicConfigManager.getConfiguration()) - .thenReturn(new DynamicConfiguration()); - - this.accounts = new Accounts( - clock, - dynamoDbExtension.getDynamoDbClient(), - dynamoDbExtension.getDynamoDbAsyncClient(), - dynamoDbExtension.getTableName(), - NUMBER_CONSTRAINT_TABLE_NAME, - PNI_CONSTRAINT_TABLE_NAME, - USERNAME_CONSTRAINT_TABLE_NAME, - SCAN_PAGE_SIZE); - } - - @Test - void testStore() { - Device device = generateDevice(1); - Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(device)); - - boolean freshUser = accounts.create(account); - - assertThat(freshUser).isTrue(); - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true); - - assertPhoneNumberConstraintExists("+14151112222", account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid()); - - freshUser = accounts.create(account); - assertThat(freshUser).isTrue(); - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true); - - assertPhoneNumberConstraintExists("+14151112222", account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid()); - } - - @Test - void testStoreMulti() { - final List devices = List.of(generateDevice(1), generateDevice(2)); - final Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), devices); - - accounts.create(account); - - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true); - - assertPhoneNumberConstraintExists("+14151112222", account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid()); - } - - @Test - void testRetrieve() { - final List devicesFirst = List.of(generateDevice(1), generateDevice(2)); - - UUID uuidFirst = UUID.randomUUID(); - UUID pniFirst = UUID.randomUUID(); - Account accountFirst = generateAccount("+14151112222", uuidFirst, pniFirst, devicesFirst); - - final List devicesSecond = List.of(generateDevice(1), generateDevice(2)); - - UUID uuidSecond = UUID.randomUUID(); - UUID pniSecond = UUID.randomUUID(); - Account accountSecond = generateAccount("+14152221111", uuidSecond, pniSecond, devicesSecond); - - accounts.create(accountFirst); - accounts.create(accountSecond); - - Optional retrievedFirst = accounts.getByE164("+14151112222"); - Optional retrievedSecond = accounts.getByE164("+14152221111"); - - assertThat(retrievedFirst.isPresent()).isTrue(); - assertThat(retrievedSecond.isPresent()).isTrue(); - - verifyStoredState("+14151112222", uuidFirst, pniFirst, null, retrievedFirst.get(), accountFirst); - verifyStoredState("+14152221111", uuidSecond, pniSecond, null, retrievedSecond.get(), accountSecond); - - retrievedFirst = accounts.getByAccountIdentifier(uuidFirst); - retrievedSecond = accounts.getByAccountIdentifier(uuidSecond); - - assertThat(retrievedFirst.isPresent()).isTrue(); - assertThat(retrievedSecond.isPresent()).isTrue(); - - verifyStoredState("+14151112222", uuidFirst, pniFirst, null, retrievedFirst.get(), accountFirst); - verifyStoredState("+14152221111", uuidSecond, pniSecond, null, retrievedSecond.get(), accountSecond); - - retrievedFirst = accounts.getByPhoneNumberIdentifier(pniFirst); - retrievedSecond = accounts.getByPhoneNumberIdentifier(pniSecond); - - assertThat(retrievedFirst.isPresent()).isTrue(); - assertThat(retrievedSecond.isPresent()).isTrue(); - - verifyStoredState("+14151112222", uuidFirst, pniFirst, null, retrievedFirst.get(), accountFirst); - verifyStoredState("+14152221111", uuidSecond, pniSecond, null, retrievedSecond.get(), accountSecond); - } - - @Test - void testRetrieveNoPni() throws JsonProcessingException { - final List devices = List.of(generateDevice(1), generateDevice(2)); - final UUID uuid = UUID.randomUUID(); - final Account account = generateAccount("+14151112222", uuid, null, devices); - - // Accounts#create enforces that newly-created accounts have a PNI, so we need to make a bit of an end-run around it - // to simulate an existing account with no PNI. - { - final TransactWriteItem phoneNumberConstraintPut = TransactWriteItem.builder() - .put( - Put.builder() - .tableName(NUMBER_CONSTRAINT_TABLE_NAME) - .item(Map.of( - Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber()), - Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()))) - .conditionExpression( - "attribute_not_exists(#number) OR (attribute_exists(#number) AND #uuid = :uuid)") - .expressionAttributeNames( - Map.of("#uuid", Accounts.KEY_ACCOUNT_UUID, - "#number", Accounts.ATTR_ACCOUNT_E164)) - .expressionAttributeValues( - Map.of(":uuid", AttributeValues.fromUUID(account.getUuid()))) - .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD) - .build()) - .build(); - - final TransactWriteItem accountPut = TransactWriteItem.builder() - .put(Put.builder() - .tableName(ACCOUNTS_TABLE_NAME) - .conditionExpression("attribute_not_exists(#number) OR #number = :number") - .expressionAttributeNames(Map.of("#number", Accounts.ATTR_ACCOUNT_E164)) - .expressionAttributeValues(Map.of(":number", AttributeValues.fromString(account.getNumber()))) - .item(Map.of( - Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid), - Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber()), - Accounts.ATTR_ACCOUNT_DATA, AttributeValues.fromByteArray(SystemMapper.getMapper().writeValueAsBytes(account)), - Accounts.ATTR_VERSION, AttributeValues.fromInt(account.getVersion()), - Accounts.ATTR_CANONICALLY_DISCOVERABLE, AttributeValues.fromBool(account.shouldBeVisibleInDirectory()))) - .build()) - .build(); - - dynamoDbExtension.getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder() - .transactItems(phoneNumberConstraintPut, accountPut) - .build()); - } - - Optional retrieved = accounts.getByE164("+14151112222"); - - assertThat(retrieved.isPresent()).isTrue(); - verifyStoredState("+14151112222", uuid, null, null, retrieved.get(), account); - - retrieved = accounts.getByAccountIdentifier(uuid); - - assertThat(retrieved.isPresent()).isTrue(); - verifyStoredState("+14151112222", uuid, null, null, retrieved.get(), account); - } - - @Test - void testOverwrite() { - Device device = generateDevice(1); - UUID firstUuid = UUID.randomUUID(); - UUID firstPni = UUID.randomUUID(); - Account account = generateAccount("+14151112222", firstUuid, firstPni, List.of(device)); - - accounts.create(account); - - final SecureRandom byteGenerator = new SecureRandom(); - final byte[] usernameHash = new byte[32]; - byteGenerator.nextBytes(usernameHash); - - // Set up the existing account to have a username hash - accounts.confirmUsernameHash(account, usernameHash); - - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), usernameHash, account, true); - - assertPhoneNumberConstraintExists("+14151112222", firstUuid); - assertPhoneNumberIdentifierConstraintExists(firstPni, firstUuid); - - accounts.update(account); - - UUID secondUuid = UUID.randomUUID(); - - device = generateDevice(1); - account = generateAccount("+14151112222", secondUuid, UUID.randomUUID(), List.of(device)); - - final boolean freshUser = accounts.create(account); - assertThat(freshUser).isFalse(); - verifyStoredState("+14151112222", firstUuid, firstPni, usernameHash, account, true); - - assertPhoneNumberConstraintExists("+14151112222", firstUuid); - assertPhoneNumberIdentifierConstraintExists(firstPni, firstUuid); - - device = generateDevice(1); - Account invalidAccount = generateAccount("+14151113333", firstUuid, UUID.randomUUID(), List.of(device)); - - assertThatThrownBy(() -> accounts.create(invalidAccount)); - } - - @Test - void testUpdate() { - Device device = generateDevice (1 ); - Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(device)); - - accounts.create(account); - - assertPhoneNumberConstraintExists("+14151112222", account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid()); - - device.setName("foobar"); - - accounts.update(account); - - assertPhoneNumberConstraintExists("+14151112222", account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid()); - - Optional retrieved = accounts.getByE164("+14151112222"); - - assertThat(retrieved.isPresent()).isTrue(); - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, retrieved.get(), account); - - retrieved = accounts.getByAccountIdentifier(account.getUuid()); - - assertThat(retrieved.isPresent()).isTrue(); - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true); - - device = generateDevice(1); - Account unknownAccount = generateAccount("+14151113333", UUID.randomUUID(), UUID.randomUUID(), List.of(device)); - - assertThatThrownBy(() -> accounts.update(unknownAccount)).isInstanceOfAny(ConditionalCheckFailedException.class); - - accounts.update(account); - - assertThat(account.getVersion()).isEqualTo(2); - - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true); - - account.setVersion(1); - - assertThatThrownBy(() -> accounts.update(account)).isInstanceOfAny(ContestedOptimisticLockException.class); - - account.setVersion(2); - - accounts.update(account); - - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testUpdateWithMockTransactionConflictException(boolean wrapException) { - - final DynamoDbAsyncClient dynamoDbAsyncClient = mock(DynamoDbAsyncClient.class); - accounts = new Accounts(mock(DynamoDbClient.class), - dynamoDbAsyncClient, dynamoDbExtension.getTableName(), - NUMBER_CONSTRAINT_TABLE_NAME, PNI_CONSTRAINT_TABLE_NAME, USERNAME_CONSTRAINT_TABLE_NAME, SCAN_PAGE_SIZE); - - Exception e = TransactionConflictException.builder().build(); - e = wrapException ? new CompletionException(e) : e; - - when(dynamoDbAsyncClient.updateItem(any(UpdateItemRequest.class))) - .thenReturn(CompletableFuture.failedFuture(e)); - - Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID()); - - assertThatThrownBy(() -> accounts.update(account)).isInstanceOfAny(ContestedOptimisticLockException.class); - } - - @Test - void testRetrieveFrom() { - List users = new ArrayList<>(); - - for (int i = 1; i <= 100; i++) { - Account account = generateAccount("+1" + String.format("%03d", i), UUID.randomUUID(), UUID.randomUUID()); - users.add(account); - accounts.create(account); - } - - users.sort((account, t1) -> UUIDComparator.staticCompare(account.getUuid(), t1.getUuid())); - - AccountCrawlChunk retrieved = accounts.getAllFromStart(10); - assertThat(retrieved.getAccounts().size()).isEqualTo(10); - - for (int i = 0; i < retrieved.getAccounts().size(); i++) { - final Account retrievedAccount = retrieved.getAccounts().get(i); - - final Account expectedAccount = users.stream() - .filter(account -> account.getUuid().equals(retrievedAccount.getUuid())) - .findAny() - .orElseThrow(); - - verifyStoredState(expectedAccount.getNumber(), expectedAccount.getUuid(), expectedAccount.getPhoneNumberIdentifier(), null, retrievedAccount, expectedAccount); - - users.remove(expectedAccount); - } - - for (int j = 0; j < 9; j++) { - retrieved = accounts.getAllFrom(retrieved.getLastUuid().orElseThrow(), 10); - assertThat(retrieved.getAccounts().size()).isEqualTo(10); - - for (int i = 0; i < retrieved.getAccounts().size(); i++) { - final Account retrievedAccount = retrieved.getAccounts().get(i); - - final Account expectedAccount = users.stream() - .filter(account -> account.getUuid().equals(retrievedAccount.getUuid())) - .findAny() - .orElseThrow(); - - verifyStoredState(expectedAccount.getNumber(), expectedAccount.getUuid(), expectedAccount.getPhoneNumberIdentifier(), null, retrievedAccount, expectedAccount); - - users.remove(expectedAccount); - } - } - - assertThat(users).isEmpty(); - } - - @Test - void testDelete() { - final Device deletedDevice = generateDevice(1); - final Account deletedAccount = generateAccount("+14151112222", UUID.randomUUID(), - UUID.randomUUID(), List.of(deletedDevice)); - final Device retainedDevice = generateDevice(1); - final Account retainedAccount = generateAccount("+14151112345", UUID.randomUUID(), - UUID.randomUUID(), List.of(retainedDevice)); - - accounts.create(deletedAccount); - accounts.create(retainedAccount); - - assertPhoneNumberConstraintExists("+14151112222", deletedAccount.getUuid()); - assertPhoneNumberIdentifierConstraintExists(deletedAccount.getPhoneNumberIdentifier(), deletedAccount.getUuid()); - assertPhoneNumberConstraintExists("+14151112345", retainedAccount.getUuid()); - assertPhoneNumberIdentifierConstraintExists(retainedAccount.getPhoneNumberIdentifier(), retainedAccount.getUuid()); - - assertThat(accounts.getByAccountIdentifier(deletedAccount.getUuid())).isPresent(); - assertThat(accounts.getByAccountIdentifier(retainedAccount.getUuid())).isPresent(); - - accounts.delete(deletedAccount.getUuid()); - - assertThat(accounts.getByAccountIdentifier(deletedAccount.getUuid())).isNotPresent(); - - assertPhoneNumberConstraintDoesNotExist(deletedAccount.getNumber()); - assertPhoneNumberIdentifierConstraintDoesNotExist(deletedAccount.getPhoneNumberIdentifier()); - - verifyStoredState(retainedAccount.getNumber(), retainedAccount.getUuid(), retainedAccount.getPhoneNumberIdentifier(), - null, accounts.getByAccountIdentifier(retainedAccount.getUuid()).get(), retainedAccount); - - { - final Account recreatedAccount = generateAccount(deletedAccount.getNumber(), UUID.randomUUID(), - UUID.randomUUID(), List.of(generateDevice(1))); - - final boolean freshUser = accounts.create(recreatedAccount); - - assertThat(freshUser).isTrue(); - assertThat(accounts.getByAccountIdentifier(recreatedAccount.getUuid())).isPresent(); - verifyStoredState(recreatedAccount.getNumber(), recreatedAccount.getUuid(), recreatedAccount.getPhoneNumberIdentifier(), - null, accounts.getByAccountIdentifier(recreatedAccount.getUuid()).get(), recreatedAccount); - - assertPhoneNumberConstraintExists(recreatedAccount.getNumber(), recreatedAccount.getUuid()); - assertPhoneNumberIdentifierConstraintExists(recreatedAccount.getPhoneNumberIdentifier(), recreatedAccount.getUuid()); - } - } - - @Test - void testMissing() { - Device device = generateDevice (1 ); - Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(device)); - - accounts.create(account); - - Optional retrieved = accounts.getByE164("+11111111"); - assertThat(retrieved.isPresent()).isFalse(); - - retrieved = accounts.getByAccountIdentifier(UUID.randomUUID()); - assertThat(retrieved.isPresent()).isFalse(); - } - - @Test - void testCanonicallyDiscoverableSet() { - Device device = generateDevice(1); - Account account = generateAccount("+14151112222", UUID.randomUUID(), UUID.randomUUID(), List.of(device)); - account.setDiscoverableByPhoneNumber(false); - accounts.create(account); - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, false); - account.setDiscoverableByPhoneNumber(true); - accounts.update(account); - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true); - account.setDiscoverableByPhoneNumber(false); - accounts.update(account); - verifyStoredState("+14151112222", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, false); - } - - @Test - public void testChangeNumber() { - final String originalNumber = "+14151112222"; - final String targetNumber = "+14151113333"; - - final UUID originalPni = UUID.randomUUID(); - final UUID targetPni = UUID.randomUUID(); - - final Device device = generateDevice(1); - final Account account = generateAccount(originalNumber, UUID.randomUUID(), originalPni, List.of(device)); - - accounts.create(account); - - assertThat(accounts.getByPhoneNumberIdentifier(originalPni)).isPresent(); - - assertPhoneNumberConstraintExists(originalNumber, account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(originalPni, account.getUuid()); - - { - final Optional retrieved = accounts.getByE164(originalNumber); - assertThat(retrieved).isPresent(); - - verifyStoredState(originalNumber, account.getUuid(), account.getPhoneNumberIdentifier(), null, retrieved.get(), account); - } - - accounts.changeNumber(account, targetNumber, targetPni); - - assertThat(accounts.getByE164(originalNumber)).isEmpty(); - assertThat(accounts.getByAccountIdentifier(originalPni)).isEmpty(); - - assertPhoneNumberConstraintDoesNotExist(originalNumber); - assertPhoneNumberIdentifierConstraintDoesNotExist(originalPni); - assertPhoneNumberConstraintExists(targetNumber, account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(targetPni, account.getUuid()); - - { - final Optional retrieved = accounts.getByE164(targetNumber); - assertThat(retrieved).isPresent(); - - verifyStoredState(targetNumber, account.getUuid(), account.getPhoneNumberIdentifier(), null, retrieved.get(), account); - - assertThat(retrieved.get().getPhoneNumberIdentifier()).isEqualTo(targetPni); - assertThat(accounts.getByPhoneNumberIdentifier(targetPni)).isPresent(); - } - } - - @Test - public void testChangeNumberConflict() { - final String originalNumber = "+14151112222"; - final String targetNumber = "+14151113333"; - - final UUID originalPni = UUID.randomUUID(); - final UUID targetPni = UUID.randomUUID(); - - final Device existingDevice = generateDevice(1); - final Account existingAccount = generateAccount(targetNumber, UUID.randomUUID(), targetPni, List.of(existingDevice)); - - final Device device = generateDevice(1); - final Account account = generateAccount(originalNumber, UUID.randomUUID(), originalPni, List.of(device)); - - accounts.create(account); - accounts.create(existingAccount); - - assertThrows(TransactionCanceledException.class, () -> accounts.changeNumber(account, targetNumber, targetPni)); - - assertPhoneNumberConstraintExists(originalNumber, account.getUuid()); - assertPhoneNumberIdentifierConstraintExists(originalPni, account.getUuid()); - assertPhoneNumberConstraintExists(targetNumber, existingAccount.getUuid()); - assertPhoneNumberIdentifierConstraintExists(targetPni, existingAccount.getUuid()); - } - - @Test - public void testChangeNumberPhoneNumberIdentifierConflict() { - final String originalNumber = "+14151112222"; - final String targetNumber = "+14151113333"; - - final Device device = generateDevice(1); - final Account account = generateAccount(originalNumber, UUID.randomUUID(), UUID.randomUUID(), List.of(device)); - - accounts.create(account); - - final UUID existingAccountIdentifier = UUID.randomUUID(); - final UUID existingPhoneNumberIdentifier = UUID.randomUUID(); - - // Artificially inject a conflicting PNI entry - dynamoDbExtension.getDynamoDbClient().putItem(PutItemRequest.builder() - .tableName(PNI_CONSTRAINT_TABLE_NAME) - .item(Map.of( - Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(existingPhoneNumberIdentifier), - Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(existingAccountIdentifier))) - .conditionExpression( - "attribute_not_exists(#pni) OR (attribute_exists(#pni) AND #uuid = :uuid)") - .expressionAttributeNames( - Map.of("#uuid", Accounts.KEY_ACCOUNT_UUID, - "#pni", Accounts.ATTR_PNI_UUID)) - .expressionAttributeValues( - Map.of(":uuid", AttributeValues.fromUUID(existingAccountIdentifier))) - .build()); - - assertThrows(TransactionCanceledException.class, () -> accounts.changeNumber(account, targetNumber, existingPhoneNumberIdentifier)); - } - - @Test - void testSwitchUsernameHashes() { - final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account); - - assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty(); - - accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)); - accounts.confirmUsernameHash(account, USERNAME_HASH_1); - - { - final Optional maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_1); - - verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(), USERNAME_HASH_1, maybeAccount.orElseThrow(), account); - } - - accounts.reserveUsernameHash(account, USERNAME_HASH_2, Duration.ofDays(1)); - accounts.confirmUsernameHash(account, USERNAME_HASH_2); - - assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty(); - assertThat(dynamoDbExtension.getDynamoDbClient() - .getItem(GetItemRequest.builder() - .tableName(USERNAME_CONSTRAINT_TABLE_NAME) - .key(Map.of(Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(USERNAME_HASH_1))) - .build()) - .item()).isEmpty(); - - { - final Optional maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_2); - - assertThat(maybeAccount).isPresent(); - verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(), - USERNAME_HASH_2, maybeAccount.get(), account); - } - } - - @Test - void testUsernameHashConflict() { - final Account firstAccount = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID()); - final Account secondAccount = generateAccount("+18005559876", UUID.randomUUID(), UUID.randomUUID()); - - accounts.create(firstAccount); - accounts.create(secondAccount); - - // first account reserves and confirms username hash - assertThatNoException().isThrownBy(() -> { - accounts.reserveUsernameHash(firstAccount, USERNAME_HASH_1, Duration.ofDays(1)); - accounts.confirmUsernameHash(firstAccount, USERNAME_HASH_1); - }); - - final Optional maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_1); - - assertThat(maybeAccount).isPresent(); - verifyStoredState(firstAccount.getNumber(), firstAccount.getUuid(), firstAccount.getPhoneNumberIdentifier(), USERNAME_HASH_1, maybeAccount.get(), firstAccount); - - // throw an error if second account tries to reserve or confirm the same username hash - assertThatExceptionOfType(ContestedOptimisticLockException.class) - .isThrownBy(() -> accounts.reserveUsernameHash(secondAccount, USERNAME_HASH_1, Duration.ofDays(1))); - assertThatExceptionOfType(ContestedOptimisticLockException.class) - .isThrownBy(() -> accounts.confirmUsernameHash(secondAccount, USERNAME_HASH_1)); - - // throw an error if first account tries to reserve or confirm the username hash that it has already confirmed - assertThatExceptionOfType(ContestedOptimisticLockException.class) - .isThrownBy(() -> accounts.reserveUsernameHash(firstAccount, USERNAME_HASH_1, Duration.ofDays(1))); - assertThatExceptionOfType(ContestedOptimisticLockException.class) - .isThrownBy(() -> accounts.confirmUsernameHash(firstAccount, USERNAME_HASH_1)); - - assertThat(secondAccount.getReservedUsernameHash()).isEmpty(); - assertThat(secondAccount.getUsernameHash()).isEmpty(); - } - - @Test - void testConfirmUsernameHashVersionMismatch() { - final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account); - accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)); - account.setVersion(account.getVersion() + 77); - - assertThatExceptionOfType(ContestedOptimisticLockException.class) - .isThrownBy(() -> accounts.confirmUsernameHash(account, USERNAME_HASH_1)); - - assertThat(account.getUsernameHash()).isEmpty(); - } - - @Test - void testClearUsername() { - final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account); - - accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)); - accounts.confirmUsernameHash(account, USERNAME_HASH_1); - assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isPresent(); - - accounts.clearUsernameHash(account); - - assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty(); - assertThat(accounts.getByAccountIdentifier(account.getUuid())) - .hasValueSatisfying(clearedAccount -> assertThat(clearedAccount.getUsernameHash()).isEmpty()); - } - - @Test - void testClearUsernameNoUsername() { - final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account); - - assertThatNoException().isThrownBy(() -> accounts.clearUsernameHash(account)); - } - - @Test - void testClearUsernameVersionMismatch() { - final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account); - - accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)); - accounts.confirmUsernameHash(account, USERNAME_HASH_1); - - account.setVersion(account.getVersion() + 12); - - assertThatExceptionOfType(ContestedOptimisticLockException.class).isThrownBy(() -> accounts.clearUsernameHash(account)); - - assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1); - } - - @Test - void testReservedUsernameHash() { - final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account1); - final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account2); - - accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1)); - assertArrayEquals(account1.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1); - assertThat(account1.getUsernameHash()).isEmpty(); - - // account 2 shouldn't be able to reserve or confirm the same username hash - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1))); - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.confirmUsernameHash(account2, USERNAME_HASH_1)); - assertThat(accounts.getByUsernameHash(USERNAME_HASH_1)).isEmpty(); - - accounts.confirmUsernameHash(account1, USERNAME_HASH_1); - assertThat(account1.getReservedUsernameHash()).isEmpty(); - assertArrayEquals(account1.getUsernameHash().orElseThrow(), USERNAME_HASH_1); - assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).get().getUuid()).isEqualTo(account1.getUuid()); - - final Map usernameConstraintRecord = dynamoDbExtension.getDynamoDbClient() - .getItem(GetItemRequest.builder() - .tableName(USERNAME_CONSTRAINT_TABLE_NAME) - .key(Map.of(Accounts.ATTR_USERNAME_HASH, AttributeValues.fromByteArray(USERNAME_HASH_1))) - .build()) - .item(); - - assertThat(usernameConstraintRecord).containsKey(Accounts.ATTR_USERNAME_HASH); - assertThat(usernameConstraintRecord).doesNotContainKey(Accounts.ATTR_TTL); - } - - @Test - void testUsernameHashAvailable() { - final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account1); - - accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1)); - assertThat(accounts.usernameHashAvailable(USERNAME_HASH_1)).isFalse(); - assertThat(accounts.usernameHashAvailable(Optional.empty(), USERNAME_HASH_1)).isFalse(); - assertThat(accounts.usernameHashAvailable(Optional.of(UUID.randomUUID()), USERNAME_HASH_1)).isFalse(); - assertThat(accounts.usernameHashAvailable(Optional.of(account1.getUuid()), USERNAME_HASH_1)).isTrue(); - - accounts.confirmUsernameHash(account1, USERNAME_HASH_1); - assertThat(accounts.usernameHashAvailable(USERNAME_HASH_1)).isFalse(); - assertThat(accounts.usernameHashAvailable(Optional.empty(), USERNAME_HASH_1)).isFalse(); - assertThat(accounts.usernameHashAvailable(Optional.of(UUID.randomUUID()), USERNAME_HASH_1)).isFalse(); - assertThat(accounts.usernameHashAvailable(Optional.of(account1.getUuid()), USERNAME_HASH_1)).isFalse(); - } - - @Test - void testConfirmReservedUsernameHashWrongAccountUuid() { - final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account1); - final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account2); - - accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1)); - assertArrayEquals(account1.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1); - assertThat(account1.getUsernameHash()).isEmpty(); - - // only account1 should be able to confirm the reserved hash - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.confirmUsernameHash(account2, USERNAME_HASH_1)); - } - - @Test - void testConfirmExpiredReservedUsernameHash() { - final Account account1 = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account1); - final Account account2 = generateAccount("+18005552222", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account2); - - accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(2)); - - Runnable runnable = () -> accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1)); - - for (int i = 0; i <= 2; i++) { - clock.pin(Instant.EPOCH.plus(Duration.ofDays(i))); - assertThrows(ContestedOptimisticLockException.class, runnable::run); - } - - // after 2 days, can reserve and confirm the hash - clock.pin(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1))); - runnable.run(); - assertEquals(account2.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1); - - accounts.confirmUsernameHash(account2, USERNAME_HASH_1); - - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(2))); - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.confirmUsernameHash(account1, USERNAME_HASH_1)); - assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).get().getUuid()).isEqualTo(account2.getUuid()); - } - - @Test - void testRetryReserveUsernameHash() { - final Account account = generateAccount("+18005551111", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account); - accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(2)); - - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(2)), - "Shouldn't be able to re-reserve same username hash (would extend ttl)"); - } - - @Test - void testReserveConfirmUsernameHashVersionConflict() { - final Account account = generateAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID()); - accounts.create(account); - account.setVersion(account.getVersion() + 12); - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1))); - assertThrows(ContestedOptimisticLockException.class, - () -> accounts.confirmUsernameHash(account, USERNAME_HASH_1)); - assertThat(account.getReservedUsernameHash()).isEmpty(); - assertThat(account.getUsernameHash()).isEmpty(); - } - - private Device generateDevice(long id) { - return DevicesHelper.createDevice(id); - } - - private Account generateAccount(String number, UUID uuid, final UUID pni) { - Device device = generateDevice(1); - return generateAccount(number, uuid, pni, List.of(device)); - } - - private Account generateAccount(String number, UUID uuid, final UUID pni, List devices) { - byte[] unidentifiedAccessKey = new byte[16]; - Random random = new Random(System.currentTimeMillis()); - Arrays.fill(unidentifiedAccessKey, (byte)random.nextInt(255)); - - return AccountsHelper.generateTestAccount(number, uuid, pni, devices, unidentifiedAccessKey); - } - - private void assertPhoneNumberConstraintExists(final String number, final UUID uuid) { - final GetItemResponse numberConstraintResponse = dynamoDbExtension.getDynamoDbClient().getItem( - GetItemRequest.builder() - .tableName(NUMBER_CONSTRAINT_TABLE_NAME) - .key(Map.of(Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(number))) - .build()); - - assertThat(numberConstraintResponse.hasItem()).isTrue(); - assertThat(AttributeValues.getUUID(numberConstraintResponse.item(), Accounts.KEY_ACCOUNT_UUID, null)).isEqualTo(uuid); - } - - private void assertPhoneNumberConstraintDoesNotExist(final String number) { - final GetItemResponse numberConstraintResponse = dynamoDbExtension.getDynamoDbClient().getItem( - GetItemRequest.builder() - .tableName(NUMBER_CONSTRAINT_TABLE_NAME) - .key(Map.of(Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(number))) - .build()); - - assertThat(numberConstraintResponse.hasItem()).isFalse(); - } - - private void assertPhoneNumberIdentifierConstraintExists(final UUID phoneNumberIdentifier, final UUID uuid) { - final GetItemResponse pniConstraintResponse = dynamoDbExtension.getDynamoDbClient().getItem( - GetItemRequest.builder() - .tableName(PNI_CONSTRAINT_TABLE_NAME) - .key(Map.of(Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(phoneNumberIdentifier))) - .build()); - - assertThat(pniConstraintResponse.hasItem()).isTrue(); - assertThat(AttributeValues.getUUID(pniConstraintResponse.item(), Accounts.KEY_ACCOUNT_UUID, null)).isEqualTo(uuid); - } - - private void assertPhoneNumberIdentifierConstraintDoesNotExist(final UUID phoneNumberIdentifier) { - final GetItemResponse pniConstraintResponse = dynamoDbExtension.getDynamoDbClient().getItem( - GetItemRequest.builder() - .tableName(PNI_CONSTRAINT_TABLE_NAME) - .key(Map.of(Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(phoneNumberIdentifier))) - .build()); - - assertThat(pniConstraintResponse.hasItem()).isFalse(); - } - - private void verifyStoredState(String number, UUID uuid, UUID pni, byte[] usernameHash, Account expecting, boolean canonicallyDiscoverable) { - final DynamoDbClient db = dynamoDbExtension.getDynamoDbClient(); - - final GetItemResponse get = db.getItem(GetItemRequest.builder() - .tableName(dynamoDbExtension.getTableName()) - .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid))) - .consistentRead(true) - .build()); - - if (get.hasItem()) { - String data = new String(get.item().get(Accounts.ATTR_ACCOUNT_DATA).b().asByteArray(), StandardCharsets.UTF_8); - assertThat(data).isNotEmpty(); - - assertThat(AttributeValues.getInt(get.item(), Accounts.ATTR_VERSION, -1)) - .isEqualTo(expecting.getVersion()); - - assertThat(AttributeValues.getBool(get.item(), Accounts.ATTR_CANONICALLY_DISCOVERABLE, - !canonicallyDiscoverable)).isEqualTo(canonicallyDiscoverable); - - assertThat(AttributeValues.getByteArray(get.item(), Accounts.ATTR_UAK, null)) - .isEqualTo(expecting.getUnidentifiedAccessKey().orElse(null)); - - assertArrayEquals(AttributeValues.getByteArray(get.item(), Accounts.ATTR_USERNAME_HASH, null), usernameHash); - - Account result = Accounts.fromItem(get.item()); - verifyStoredState(number, uuid, pni, usernameHash, result, expecting); - } else { - throw new AssertionError("No data"); - } - } - - private void verifyStoredState(String number, UUID uuid, UUID pni, byte[] usernameHash, Account result, Account expecting) { - assertThat(result.getNumber()).isEqualTo(number); - assertThat(result.getPhoneNumberIdentifier()).isEqualTo(pni); - assertThat(result.getLastSeen()).isEqualTo(expecting.getLastSeen()); - assertThat(result.getUuid()).isEqualTo(uuid); - assertThat(result.getVersion()).isEqualTo(expecting.getVersion()); - assertArrayEquals(result.getUsernameHash().orElse(null), usernameHash); - assertThat(Arrays.equals(result.getUnidentifiedAccessKey().get(), expecting.getUnidentifiedAccessKey().get())).isTrue(); - - for (Device expectingDevice : expecting.getDevices()) { - Device resultDevice = result.getDevice(expectingDevice.getId()).get(); - assertThat(resultDevice.getApnId()).isEqualTo(expectingDevice.getApnId()); - assertThat(resultDevice.getGcmId()).isEqualTo(expectingDevice.getGcmId()); - assertThat(resultDevice.getLastSeen()).isEqualTo(expectingDevice.getLastSeen()); - assertThat(resultDevice.getSignedPreKey().getPublicKey()).isEqualTo(expectingDevice.getSignedPreKey().getPublicKey()); - assertThat(resultDevice.getSignedPreKey().getKeyId()).isEqualTo(expectingDevice.getSignedPreKey().getKeyId()); - assertThat(resultDevice.getSignedPreKey().getSignature()).isEqualTo(expectingDevice.getSignedPreKey().getSignature()); - assertThat(resultDevice.getFetchesMessages()).isEqualTo(expectingDevice.getFetchesMessages()); - assertThat(resultDevice.getUserAgent()).isEqualTo(expectingDevice.getUserAgent()); - assertThat(resultDevice.getName()).isEqualTo(expectingDevice.getName()); - assertThat(resultDevice.getCreated()).isEqualTo(expectingDevice.getCreated()); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManagerTest.java deleted file mode 100644 index 19ff99867..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManagerTest.java +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.controllers.StaleDevicesException; -import org.whispersystems.textsecuregcm.entities.IncomingMessage; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.push.MessageSender; - -public class ChangeNumberManagerTest { - private AccountsManager accountsManager; - private MessageSender messageSender; - private ChangeNumberManager changeNumberManager; - - private Map updatedPhoneNumberIdentifiersByAccount; - - @BeforeEach - void setUp() throws Exception { - accountsManager = mock(AccountsManager.class); - messageSender = mock(MessageSender.class); - changeNumberManager = new ChangeNumberManager(messageSender, accountsManager); - - updatedPhoneNumberIdentifiersByAccount = new HashMap<>(); - - when(accountsManager.changeNumber(any(), any(), any(), any(), any())).thenAnswer((Answer)invocation -> { - final Account account = invocation.getArgument(0, Account.class); - final String number = invocation.getArgument(1, String.class); - - final UUID uuid = account.getUuid(); - final List devices = account.getDevices(); - - final UUID updatedPni = UUID.randomUUID(); - updatedPhoneNumberIdentifiersByAccount.put(account, updatedPni); - - final Account updatedAccount = mock(Account.class); - when(updatedAccount.getUuid()).thenReturn(uuid); - when(updatedAccount.getNumber()).thenReturn(number); - when(updatedAccount.getPhoneNumberIdentifier()).thenReturn(updatedPni); - when(updatedAccount.getDevices()).thenReturn(devices); - for (long i = 1; i <= 3; i++) { - final Optional d = account.getDevice(i); - when(updatedAccount.getDevice(i)).thenReturn(d); - } - - return updatedAccount; - }); - } - - @Test - void changeNumberNoMessages() throws Exception { - Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005551234"); - changeNumberManager.changeNumber(account, "+18025551234", null, null, null, null); - verify(accountsManager).changeNumber(account, "+18025551234", null, null, null); - verify(accountsManager, never()).updateDevice(any(), eq(1L), any()); - verify(messageSender, never()).sendMessage(eq(account), any(), any(), eq(false)); - } - - @Test - void changeNumberSetPrimaryDevicePrekey() throws Exception { - Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005551234"); - var prekeys = Map.of(1L, new SignedPreKey()); - final String pniIdentityKey = "pni-identity-key"; - - changeNumberManager.changeNumber(account, "+18025551234", pniIdentityKey, prekeys, Collections.emptyList(), Collections.emptyMap()); - verify(accountsManager).changeNumber(account, "+18025551234", pniIdentityKey, prekeys, Collections.emptyMap()); - verify(messageSender, never()).sendMessage(eq(account), any(), any(), eq(false)); - } - - @Test - void changeNumberSetPrimaryDevicePrekeyAndSendMessages() throws Exception { - final String originalE164 = "+18005551234"; - final String changedE164 = "+18025551234"; - final UUID aci = UUID.randomUUID(); - final UUID pni = UUID.randomUUID(); - - final Account account = mock(Account.class); - when(account.getNumber()).thenReturn(originalE164); - when(account.getUuid()).thenReturn(aci); - when(account.getPhoneNumberIdentifier()).thenReturn(pni); - - final Device d2 = mock(Device.class); - when(d2.isEnabled()).thenReturn(true); - when(d2.getId()).thenReturn(2L); - - when(account.getDevice(2L)).thenReturn(Optional.of(d2)); - when(account.getDevices()).thenReturn(List.of(d2)); - - final String pniIdentityKey = "pni-identity-key"; - final Map prekeys = Map.of(1L, new SignedPreKey(), 2L, new SignedPreKey()); - final Map registrationIds = Map.of(1L, 17, 2L, 19); - - final IncomingMessage msg = mock(IncomingMessage.class); - when(msg.destinationDeviceId()).thenReturn(2L); - when(msg.content()).thenReturn(Base64.getEncoder().encodeToString(new byte[]{1})); - - changeNumberManager.changeNumber(account, changedE164, pniIdentityKey, prekeys, List.of(msg), registrationIds); - - verify(accountsManager).changeNumber(account, changedE164, pniIdentityKey, prekeys, registrationIds); - - final ArgumentCaptor envelopeCaptor = ArgumentCaptor.forClass(MessageProtos.Envelope.class); - verify(messageSender).sendMessage(any(), eq(d2), envelopeCaptor.capture(), eq(false)); - - final MessageProtos.Envelope envelope = envelopeCaptor.getValue(); - - assertEquals(aci, UUID.fromString(envelope.getDestinationUuid())); - assertEquals(aci, UUID.fromString(envelope.getSourceUuid())); - assertEquals(Device.MASTER_ID, envelope.getSourceDevice()); - assertEquals(updatedPhoneNumberIdentifiersByAccount.get(account), UUID.fromString(envelope.getUpdatedPni())); - } - - @Test - void changeNumberMismatchedRegistrationId() { - final Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005551234"); - - final List devices = new ArrayList<>(); - - for (int i = 1; i <= 3; i++) { - final Device device = mock(Device.class); - when(device.getId()).thenReturn((long) i); - when(device.isEnabled()).thenReturn(true); - when(device.getRegistrationId()).thenReturn(i); - - devices.add(device); - when(account.getDevice(i)).thenReturn(Optional.of(device)); - } - - when(account.getDevices()).thenReturn(devices); - - final List messages = List.of( - new IncomingMessage(1, 2, 1, "foo"), - new IncomingMessage(1, 3, 1, "foo")); - - final Map preKeys = Map.of(1L, new SignedPreKey(), 2L, new SignedPreKey(), 3L, new SignedPreKey()); - final Map registrationIds = Map.of(1L, 17, 2L, 47, 3L, 89); - - assertThrows(StaleDevicesException.class, - () -> changeNumberManager.changeNumber(account, "+18005559876", "pni-identity-key", preKeys, messages, registrationIds)); - } - - @Test - void changeNumberMissingData() { - final Account account = mock(Account.class); - when(account.getNumber()).thenReturn("+18005551234"); - - final List devices = new ArrayList<>(); - - for (int i = 1; i <= 3; i++) { - final Device device = mock(Device.class); - when(device.getId()).thenReturn((long) i); - when(device.isEnabled()).thenReturn(true); - when(device.getRegistrationId()).thenReturn(i); - - devices.add(device); - when(account.getDevice(i)).thenReturn(Optional.of(device)); - } - - when(account.getDevices()).thenReturn(devices); - - final List messages = List.of( - new IncomingMessage(1, 2, 2, "foo"), - new IncomingMessage(1, 3, 3, "foo")); - - final Map registrationIds = Map.of(1L, 17, 2L, 47, 3L, 89); - - assertThrows(IllegalArgumentException.class, - () -> changeNumberManager.changeNumber(account, "+18005559876", "pni-identity-key", null, messages, registrationIds)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ContactDiscoveryWriterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ContactDiscoveryWriterTest.java deleted file mode 100644 index 5fd24dfc0..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ContactDiscoveryWriterTest.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Stream; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -public class ContactDiscoveryWriterTest { - - @ParameterizedTest - @MethodSource - void testUpdatesOnChange(boolean canonicallyDiscoverable, boolean shouldBeVisible, boolean updateCalled) throws AccountDatabaseCrawlerRestartException { - AccountsManager mgr = mock(AccountsManager.class); - UUID uuid = UUID.randomUUID(); - Account acct = mock(Account.class); - when(acct.getUuid()).thenReturn(uuid); - when(acct.isCanonicallyDiscoverable()).thenReturn(canonicallyDiscoverable); - when(acct.shouldBeVisibleInDirectory()).thenReturn(shouldBeVisible); - when(mgr.getByAccountIdentifier(uuid)).thenReturn(Optional.of(acct)); - ContactDiscoveryWriter writer = new ContactDiscoveryWriter(mgr); - writer.onCrawlChunk(Optional.empty(), List.of(acct)); - verify(mgr, times(updateCalled ? 1 : 0)).update(acct, ContactDiscoveryWriter.NOOP_UPDATER); - } - - static Stream testUpdatesOnChange() { - return Stream.of( - Arguments.of(true, true, false), - Arguments.of(false, false, false), - Arguments.of(true, false, true), - Arguments.of(false, true, true)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManagerTest.java deleted file mode 100644 index 128b07216..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManagerTest.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.lang.Thread.State; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.api.function.Executable; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.Projection; -import software.amazon.awssdk.services.dynamodb.model.ProjectionType; -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class DeletedAccountsManagerTest { - - private static final String NEEDS_RECONCILIATION_INDEX_NAME = "needs_reconciliation_test"; - - @RegisterExtension - static final DynamoDbExtension DELETED_ACCOUNTS_DYNAMODB_EXTENSION = DynamoDbExtension.builder() - .tableName("deleted_accounts_test") - .hashKey(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S).build()) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) - .attributeType(ScalarAttributeType.N) - .build()) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .globalSecondaryIndex(GlobalSecondaryIndex.builder() - .indexName(NEEDS_RECONCILIATION_INDEX_NAME) - .keySchema( - KeySchemaElement.builder().attributeName(DeletedAccounts.KEY_ACCOUNT_E164).keyType(KeyType.HASH).build(), - KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) - .keyType(KeyType.RANGE).build()) - .projection(Projection.builder().projectionType(ProjectionType.INCLUDE) - .nonKeyAttributes(DeletedAccounts.ATTR_ACCOUNT_UUID).build()) - .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) - .build()) - .globalSecondaryIndex(GlobalSecondaryIndex.builder() - .indexName(DeletedAccounts.UUID_TO_E164_INDEX_NAME) - .keySchema( - KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID).keyType(KeyType.HASH).build() - ) - .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build()) - .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) - .build()) - .build(); - - @RegisterExtension - static DynamoDbExtension DELETED_ACCOUNTS_LOCK_DYNAMODB_EXTENSION = DynamoDbExtension.builder() - .tableName("deleted_accounts_lock_test") - .hashKey(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S).build()) - .build(); - - private DeletedAccounts deletedAccounts; - private DeletedAccountsManager deletedAccountsManager; - - @BeforeEach - void setUp() { - deletedAccounts = new DeletedAccounts(DELETED_ACCOUNTS_DYNAMODB_EXTENSION.getDynamoDbClient(), - DELETED_ACCOUNTS_DYNAMODB_EXTENSION.getTableName(), - NEEDS_RECONCILIATION_INDEX_NAME); - - deletedAccountsManager = new DeletedAccountsManager(deletedAccounts, - DELETED_ACCOUNTS_LOCK_DYNAMODB_EXTENSION.getLegacyDynamoClient(), - DELETED_ACCOUNTS_LOCK_DYNAMODB_EXTENSION.getTableName()); - } - - @Test - void testLockAndTake() throws InterruptedException { - final UUID uuid = UUID.randomUUID(); - final String e164 = "+18005551234"; - - deletedAccounts.put(uuid, e164, true); - deletedAccountsManager.lockAndTake(e164, maybeUuid -> assertEquals(Optional.of(uuid), maybeUuid)); - assertEquals(Optional.empty(), deletedAccounts.findUuid(e164)); - } - - @Test - void testLockAndTakeWithException() { - final UUID uuid = UUID.randomUUID(); - final String e164 = "+18005551234"; - - deletedAccounts.put(uuid, e164, true); - - assertThrows(RuntimeException.class, () -> deletedAccountsManager.lockAndTake(e164, maybeUuid -> { - assertEquals(Optional.of(uuid), maybeUuid); - throw new RuntimeException("OH NO"); - })); - - assertEquals(Optional.of(uuid), deletedAccounts.findUuid(e164)); - } - - @Test - void testReconciliationLockContention() throws ChunkProcessingFailedException { - - final UUID[] uuids = new UUID[3]; - final String[] e164s = new String[uuids.length]; - - for (int i = 0; i < uuids.length; i++) { - uuids[i] = UUID.randomUUID(); - e164s[i] = String.format("+1800555%04d", i); - } - - final Map expectedReconciledAccounts = new HashMap<>(); - - for (int i = 0; i < uuids.length; i++) { - deletedAccounts.put(uuids[i], e164s[i], true); - expectedReconciledAccounts.put(e164s[i], uuids[i]); - } - - final UUID replacedUUID = UUID.randomUUID(); - final Map reconciledAccounts = new HashMap<>(); - - final Thread putThread = new Thread(() -> { - try { - deletedAccountsManager.lockAndPut(e164s[0], () -> replacedUUID); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }, - getClass().getSimpleName() + "-put"); - - final Thread reconcileThread = new Thread(() -> { - try { - deletedAccountsManager.lockAndReconcileAccounts(uuids.length, deletedAccounts -> { - // We hold the lock for the first account, so a thread trying to operate on that first count should block - // waiting for the lock. - putThread.start(); - - // Make sure the other thread really does actually block at some point - while (putThread.getState() != State.TIMED_WAITING) { - Thread.yield(); - } - - deletedAccounts.forEach(pair -> reconciledAccounts.put(pair.second(), pair.first())); - return reconciledAccounts.keySet(); - }); - } catch (ChunkProcessingFailedException e) { - throw new AssertionError(e); - } - }, getClass().getSimpleName() + "-reconcile"); - - reconcileThread.start(); - - assertDoesNotThrow((Executable) reconcileThread::join); - assertDoesNotThrow((Executable) putThread::join); - - assertEquals(expectedReconciledAccounts, reconciledAccounts); - - // The "put" thread should have completed after the reconciliation thread wrapped up. We can verify that's true by - // reconciling again; the updated account (and only that account) should appear in the "needs reconciliation" list. - deletedAccountsManager.lockAndReconcileAccounts(uuids.length, deletedAccounts -> { - assertEquals(1, deletedAccounts.size()); - assertEquals(replacedUUID, deletedAccounts.get(0).first()); - assertEquals(e164s[0], deletedAccounts.get(0).second()); - - return List.of(deletedAccounts.get(0).second()); - }); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTest.java deleted file mode 100644 index a48b161db..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTest.java +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.util.Pair; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.Projection; -import software.amazon.awssdk.services.dynamodb.model.ProjectionType; -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class DeletedAccountsTest { - - private static final String NEEDS_RECONCILIATION_INDEX_NAME = "needs_reconciliation_test"; - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName("deleted_accounts_test") - .hashKey(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.KEY_ACCOUNT_E164) - .attributeType(ScalarAttributeType.S).build()) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) - .attributeType(ScalarAttributeType.N) - .build()) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .globalSecondaryIndex(GlobalSecondaryIndex.builder() - .indexName(NEEDS_RECONCILIATION_INDEX_NAME) - .keySchema( - KeySchemaElement.builder().attributeName(DeletedAccounts.KEY_ACCOUNT_E164).keyType(KeyType.HASH).build(), - KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) - .keyType(KeyType.RANGE).build()) - .projection(Projection.builder().projectionType(ProjectionType.INCLUDE) - .nonKeyAttributes(DeletedAccounts.ATTR_ACCOUNT_UUID).build()) - .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) - .build()) - .globalSecondaryIndex(GlobalSecondaryIndex.builder() - .indexName(DeletedAccounts.UUID_TO_E164_INDEX_NAME) - .keySchema( - KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID).keyType(KeyType.HASH).build() - ) - .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build()) - .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) - .build()) - .build(); - - private DeletedAccounts deletedAccounts; - - @BeforeEach - void setUp() { - deletedAccounts = new DeletedAccounts(dynamoDbExtension.getDynamoDbClient(), - dynamoDbExtension.getTableName(), - NEEDS_RECONCILIATION_INDEX_NAME); - } - - @Test - void testPutList() { - UUID firstUuid = UUID.randomUUID(); - UUID secondUuid = UUID.randomUUID(); - UUID thirdUuid = UUID.randomUUID(); - - String firstNumber = "+14152221234"; - String secondNumber = "+14152225678"; - String thirdNumber = "+14159998765"; - - assertTrue(deletedAccounts.listAccountsToReconcile(1).isEmpty()); - - deletedAccounts.put(firstUuid, firstNumber, true); - deletedAccounts.put(secondUuid, secondNumber, true); - deletedAccounts.put(thirdUuid, thirdNumber, true); - - assertEquals(1, deletedAccounts.listAccountsToReconcile(1).size()); - - assertTrue(deletedAccounts.listAccountsToReconcile(10).containsAll( - List.of( - new Pair<>(firstUuid, firstNumber), - new Pair<>(secondUuid, secondNumber)))); - - deletedAccounts.markReconciled(List.of(firstNumber, secondNumber)); - - assertEquals(List.of(new Pair<>(thirdUuid, thirdNumber)), deletedAccounts.listAccountsToReconcile(10)); - - deletedAccounts.markReconciled(List.of(thirdNumber)); - - assertTrue(deletedAccounts.listAccountsToReconcile(1).isEmpty()); - } - - @Test - void testPutFind() { - final UUID uuid = UUID.randomUUID(); - final String e164 = "+18005551234"; - - assertEquals(Optional.empty(), deletedAccounts.findUuid(e164)); - - deletedAccounts.put(uuid, e164, true); - - assertEquals(Optional.of(uuid), deletedAccounts.findUuid(e164)); - } - - @Test - void testRemove() { - final UUID uuid = UUID.randomUUID(); - final String e164 = "+18005551234"; - - assertEquals(Optional.empty(), deletedAccounts.findUuid(e164)); - - deletedAccounts.put(uuid, e164, true); - - assertEquals(Optional.of(uuid), deletedAccounts.findUuid(e164)); - - deletedAccounts.remove(e164); - - assertEquals(Optional.empty(), deletedAccounts.findUuid(e164)); - } - - @Test - void testGetAccountsNeedingReconciliation() { - final UUID firstUuid = UUID.randomUUID(); - final UUID secondUuid = UUID.randomUUID(); - - final String firstNumber = "+14152221234"; - final String secondNumber = "+14152225678"; - final String thirdNumber = "+14159998765"; - - assertEquals(Collections.emptySet(), - deletedAccounts.getAccountsNeedingReconciliation(List.of(firstNumber, secondNumber, thirdNumber))); - - deletedAccounts.put(firstUuid, firstNumber, true); - deletedAccounts.put(secondUuid, secondNumber, true); - - assertEquals(Set.of(firstNumber, secondNumber), - deletedAccounts.getAccountsNeedingReconciliation(List.of(firstNumber, secondNumber, thirdNumber))); - } - - @Test - void testGetAccountsNeedingReconciliationLargeBatch() { - final int itemCount = (DeletedAccounts.GET_BATCH_SIZE * 3) + 1; - - final Set expectedAccountsNeedingReconciliation = new HashSet<>(itemCount); - - for (int i = 0; i < itemCount; i++) { - final String e164 = String.format("+18000555%04d", i); - - deletedAccounts.put(UUID.randomUUID(), e164, true); - expectedAccountsNeedingReconciliation.add(e164); - } - - final Set accountsNeedingReconciliation = - deletedAccounts.getAccountsNeedingReconciliation(expectedAccountsNeedingReconciliation); - - assertEquals(expectedAccountsNeedingReconciliation, accountsNeedingReconciliation); - } - - - @Test - void testFindE164() { - assertEquals(Optional.empty(), deletedAccounts.findE164(UUID.randomUUID())); - - final UUID uuid = UUID.randomUUID(); - final String e164 = "+18005551234"; - - deletedAccounts.put(uuid, e164, true); - - assertEquals(Optional.of(e164), deletedAccounts.findE164(uuid)); - } - - @Test - void testFindUUID() { - final String e164 = "+18005551234"; - - assertEquals(Optional.empty(), deletedAccounts.findUuid(e164)); - - final UUID uuid = UUID.randomUUID(); - - deletedAccounts.put(uuid, e164, true); - - assertEquals(Optional.of(uuid), deletedAccounts.findUuid(e164)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java deleted file mode 100644 index 237873593..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; - -import java.time.Duration; -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; - -class DeviceTest { - - @ParameterizedTest - @MethodSource - void testIsEnabled(final boolean master, final boolean fetchesMessages, final String apnId, final String gcmId, - final SignedPreKey signedPreKey, final Duration timeSinceLastSeen, final boolean expectEnabled) { - - final long lastSeen = System.currentTimeMillis() - timeSinceLastSeen.toMillis(); - - final Device device = new Device(); - device.setId(master ? Device.MASTER_ID : Device.MASTER_ID + 1); - device.setFetchesMessages(fetchesMessages); - device.setApnId(apnId); - device.setGcmId(gcmId); - device.setSignedPreKey(signedPreKey); - device.setCreated(lastSeen); - device.setLastSeen(lastSeen); - - assertEquals(expectEnabled, device.isEnabled()); - } - - private static Stream testIsEnabled() { - return Stream.of( - // master fetchesMessages apnId gcmId signedPreKey lastSeen expectEnabled - Arguments.of(true, false, null, null, null, Duration.ofDays(60), false), - Arguments.of(true, false, null, null, null, Duration.ofDays(1), false), - Arguments.of(true, false, null, null, mock(SignedPreKey.class), Duration.ofDays(60), false), - Arguments.of(true, false, null, null, mock(SignedPreKey.class), Duration.ofDays(1), false), - Arguments.of(true, false, null, "gcm-id", null, Duration.ofDays(60), false), - Arguments.of(true, false, null, "gcm-id", null, Duration.ofDays(1), false), - Arguments.of(true, false, null, "gcm-id", mock(SignedPreKey.class), Duration.ofDays(60), true), - Arguments.of(true, false, null, "gcm-id", mock(SignedPreKey.class), Duration.ofDays(1), true), - Arguments.of(true, false, "apn-id", null, null, Duration.ofDays(60), false), - Arguments.of(true, false, "apn-id", null, null, Duration.ofDays(1), false), - Arguments.of(true, false, "apn-id", null, mock(SignedPreKey.class), Duration.ofDays(60), true), - Arguments.of(true, false, "apn-id", null, mock(SignedPreKey.class), Duration.ofDays(1), true), - Arguments.of(true, true, null, null, null, Duration.ofDays(60), false), - Arguments.of(true, true, null, null, null, Duration.ofDays(1), false), - Arguments.of(true, true, null, null, mock(SignedPreKey.class), Duration.ofDays(60), true), - Arguments.of(true, true, null, null, mock(SignedPreKey.class), Duration.ofDays(1), true), - Arguments.of(false, false, null, null, null, Duration.ofDays(60), false), - Arguments.of(false, false, null, null, null, Duration.ofDays(1), false), - Arguments.of(false, false, null, null, mock(SignedPreKey.class), Duration.ofDays(60), false), - Arguments.of(false, false, null, null, mock(SignedPreKey.class), Duration.ofDays(1), false), - Arguments.of(false, false, null, "gcm-id", null, Duration.ofDays(60), false), - Arguments.of(false, false, null, "gcm-id", null, Duration.ofDays(1), false), - Arguments.of(false, false, null, "gcm-id", mock(SignedPreKey.class), Duration.ofDays(60), false), - Arguments.of(false, false, null, "gcm-id", mock(SignedPreKey.class), Duration.ofDays(1), true), - Arguments.of(false, false, "apn-id", null, null, Duration.ofDays(60), false), - Arguments.of(false, false, "apn-id", null, null, Duration.ofDays(1), false), - Arguments.of(false, false, "apn-id", null, mock(SignedPreKey.class), Duration.ofDays(60), false), - Arguments.of(false, false, "apn-id", null, mock(SignedPreKey.class), Duration.ofDays(1), true), - Arguments.of(false, true, null, null, null, Duration.ofDays(60), false), - Arguments.of(false, true, null, null, null, Duration.ofDays(1), false), - Arguments.of(false, true, null, null, mock(SignedPreKey.class), Duration.ofDays(60), false), - Arguments.of(false, true, null, null, mock(SignedPreKey.class), Duration.ofDays(1), true) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManagerTest.java deleted file mode 100644 index d0b35743d..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManagerTest.java +++ /dev/null @@ -1,152 +0,0 @@ -package org.whispersystems.textsecuregcm.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.appconfigdata.AppConfigDataClient; -import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationRequest; -import software.amazon.awssdk.services.appconfigdata.model.GetLatestConfigurationResponse; -import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionRequest; -import software.amazon.awssdk.services.appconfigdata.model.StartConfigurationSessionResponse; - -class DynamicConfigurationManagerTest { - - private static final SdkBytes VALID_CONFIG = SdkBytes.fromUtf8String(""" - test: true - captcha: - scoreFloor: 1.0 - """); - - private DynamicConfigurationManager dynamicConfigurationManager; - private AppConfigDataClient appConfig; - private StartConfigurationSessionRequest startConfigurationSession; - - @BeforeEach - void setup() { - this.appConfig = mock(AppConfigDataClient.class); - this.dynamicConfigurationManager = new DynamicConfigurationManager<>( - appConfig, "foo", "bar", "baz", DynamicConfiguration.class); - this.startConfigurationSession = StartConfigurationSessionRequest.builder() - .applicationIdentifier("foo") - .environmentIdentifier("bar") - .configurationProfileIdentifier("baz") - .build(); - } - - @Test - void testGetInitialConfig() { - when(appConfig.startConfigurationSession(startConfigurationSession)) - .thenReturn(StartConfigurationSessionResponse.builder() - .initialConfigurationToken("initial") - .build()); - - // call with initial token will return a real config - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder() - .configurationToken("initial").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(VALID_CONFIG) - .nextPollConfigurationToken("next").build()); - - // subsequent config calls will return empty (no update) - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder(). - configurationToken("next").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String("")) - .nextPollConfigurationToken("next").build()); - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - dynamicConfigurationManager.start(); - assertThat(dynamicConfigurationManager.getConfiguration()).isNotNull(); - }); - } - - @Test - void testBadConfig() { - when(appConfig.startConfigurationSession(startConfigurationSession)) - .thenReturn(StartConfigurationSessionResponse.builder() - .initialConfigurationToken("initial") - .build()); - - // call with initial token will return a bad config - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder() - .configurationToken("initial").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String("zzz")) - .nextPollConfigurationToken("goodconfig").build()); - - // next config call will return a good config - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder(). - configurationToken("goodconfig").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(VALID_CONFIG) - .nextPollConfigurationToken("next").build()); - - // all subsequent config calls will return an empty config (no update) - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder(). - configurationToken("next").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String("")) - .nextPollConfigurationToken("next").build()); - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - dynamicConfigurationManager.start(); - assertThat(dynamicConfigurationManager.getConfiguration()).isNotNull(); - }); - } - - @Test - void testGetConfigMultiple() { - when(appConfig.startConfigurationSession(startConfigurationSession)) - .thenReturn(StartConfigurationSessionResponse.builder() - .initialConfigurationToken("0") - .build()); - - // initial config - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder(). - configurationToken("0").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(VALID_CONFIG) - .nextPollConfigurationToken("1").build()); - - // config update with a real config - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder(). - configurationToken("1").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String(""" - experiments: - test: - enrollmentPercentage: 50 - captcha: - scoreFloor: 1.0 - """)) - .nextPollConfigurationToken("2").build()); - - // all subsequent are no update - when(appConfig.getLatestConfiguration(GetLatestConfigurationRequest.builder(). - configurationToken("2").build())) - .thenReturn(GetLatestConfigurationResponse.builder() - .configuration(SdkBytes.fromUtf8String("")) - .nextPollConfigurationToken("2").build()); - - // the internal waiting done by dynamic configuration manager catches the InterruptedException used - // by JUnit’s @Timeout, so we use assertTimeoutPreemptively - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - // we should eventually get the updated config (or the test will timeout) - dynamicConfigurationManager.start(); - while (dynamicConfigurationManager.getConfiguration().getExperimentEnrollmentConfiguration("test").isEmpty()) { - Thread.sleep(100); - } - assertThat( - dynamicConfigurationManager.getConfiguration().getExperimentEnrollmentConfiguration("test").get() - .getEnrollmentPercentage()).isEqualTo(50); - }); - - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java deleted file mode 100644 index 36af6bf1c..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java +++ /dev/null @@ -1,250 +0,0 @@ -package org.whispersystems.textsecuregcm.storage; - -import com.almworks.sqlite4java.SQLite; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.amazonaws.services.dynamodbv2.local.main.ServerRunner; -import com.amazonaws.services.dynamodbv2.local.server.DynamoDBProxyServer; -import java.net.ServerSocket; -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import org.junit.jupiter.api.extension.AfterEachCallback; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; -import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.LocalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; - -public class DynamoDbExtension implements BeforeEachCallback, AfterEachCallback { - - static final String DEFAULT_TABLE_NAME = "test_table"; - - static final ProvisionedThroughput DEFAULT_PROVISIONED_THROUGHPUT = ProvisionedThroughput.builder() - .readCapacityUnits(20L) - .writeCapacityUnits(20L) - .build(); - - private static final AtomicBoolean libraryLoaded = new AtomicBoolean(); - - private DynamoDBProxyServer server; - private int port; - - private final String tableName; - private final String hashKeyName; - private final String rangeKeyName; - - private final List attributeDefinitions; - private final List globalSecondaryIndexes; - private final List localSecondaryIndexes; - - private final long readCapacityUnits; - private final long writeCapacityUnits; - - private DynamoDbClient dynamoDB2; - private DynamoDbAsyncClient dynamoAsyncDB2; - private AmazonDynamoDB legacyDynamoClient; - - private DynamoDbExtension(String tableName, String hashKey, String rangeKey, - List attributeDefinitions, List globalSecondaryIndexes, - final List localSecondaryIndexes, - long readCapacityUnits, - long writeCapacityUnits) { - - this.tableName = tableName; - this.hashKeyName = hashKey; - this.rangeKeyName = rangeKey; - this.localSecondaryIndexes = localSecondaryIndexes; - - this.readCapacityUnits = readCapacityUnits; - this.writeCapacityUnits = writeCapacityUnits; - - this.attributeDefinitions = attributeDefinitions; - this.globalSecondaryIndexes = globalSecondaryIndexes; - } - - public static DynamoDbExtensionBuilder builder() { - return new DynamoDbExtensionBuilder(); - } - - private static void loadLibrary() { - // to avoid noise in the logs from “library already loaded” warnings, we make sure we only set it once - if (libraryLoaded.get()) { - return; - } - if (libraryLoaded.compareAndSet(false, true)) { - // if you see a library failed to load error, you need to run mvn test-compile at least once first - SQLite.setLibraryPath("target/lib"); - } - } - - @Override - public void afterEach(ExtensionContext context) { - stopServer(); - } - - /** - * For use in integration tests that want to test resiliency/error handling - */ - public void stopServer() { - try { - server.stop(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public void beforeEach(ExtensionContext context) throws Exception { - - startServer(); - - initializeClient(); - - createTable(); - } - - private void createTable() { - KeySchemaElement[] keySchemaElements; - if (rangeKeyName == null) { - keySchemaElements = new KeySchemaElement[] { - KeySchemaElement.builder().attributeName(hashKeyName).keyType(KeyType.HASH).build(), - }; - } else { - keySchemaElements = new KeySchemaElement[] { - KeySchemaElement.builder().attributeName(hashKeyName).keyType(KeyType.HASH).build(), - KeySchemaElement.builder().attributeName(rangeKeyName).keyType(KeyType.RANGE).build(), - }; - } - - final CreateTableRequest createTableRequest = CreateTableRequest.builder() - .tableName(tableName) - .keySchema(keySchemaElements) - .attributeDefinitions(attributeDefinitions.isEmpty() ? null : attributeDefinitions) - .globalSecondaryIndexes(globalSecondaryIndexes.isEmpty() ? null : globalSecondaryIndexes) - .localSecondaryIndexes(localSecondaryIndexes.isEmpty() ? null : localSecondaryIndexes) - .provisionedThroughput(ProvisionedThroughput.builder() - .readCapacityUnits(readCapacityUnits) - .writeCapacityUnits(writeCapacityUnits) - .build()) - .build(); - - getDynamoDbClient().createTable(createTableRequest); - } - - private void startServer() throws Exception { - // Even though we're using AWS SDK v2, Dynamo's local implementation's canonical location - // is within v1 (https://github.com/aws/aws-sdk-java-v2/issues/982). This does support - // v2 clients, though. - loadLibrary(); - ServerSocket serverSocket = new ServerSocket(0); - serverSocket.setReuseAddress(false); - port = serverSocket.getLocalPort(); - serverSocket.close(); - server = ServerRunner.createServerFromCommandLineArgs(new String[]{"-inMemory", "-port", String.valueOf(port)}); - server.start(); - } - - private void initializeClient() { - dynamoDB2 = DynamoDbClient.builder() - .endpointOverride(URI.create("http://localhost:" + port)) - .region(Region.of("local-test-region")) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create("accessKey", "secretKey"))) - .build(); - dynamoAsyncDB2 = DynamoDbAsyncClient.builder() - .endpointOverride(URI.create("http://localhost:" + port)) - .region(Region.of("local-test-region")) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create("accessKey", "secretKey"))) - .build(); - legacyDynamoClient = AmazonDynamoDBClientBuilder.standard() - .withEndpointConfiguration( - new AwsClientBuilder.EndpointConfiguration("http://localhost:" + port, "local-test-region")) - .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials("accessKey", "secretKey"))) - .build(); - } - - public static class DynamoDbExtensionBuilder { - - private String tableName = DEFAULT_TABLE_NAME; - - private String hashKey; - private String rangeKey; - - private final List attributeDefinitions = new ArrayList<>(); - private final List globalSecondaryIndexes = new ArrayList<>(); - private final List localSecondaryIndexes = new ArrayList<>(); - - private final long readCapacityUnits = DEFAULT_PROVISIONED_THROUGHPUT.readCapacityUnits(); - private final long writeCapacityUnits = DEFAULT_PROVISIONED_THROUGHPUT.writeCapacityUnits(); - - private DynamoDbExtensionBuilder() { - - } - - public DynamoDbExtensionBuilder tableName(String databaseName) { - this.tableName = databaseName; - return this; - } - - public DynamoDbExtensionBuilder hashKey(String hashKey) { - this.hashKey = hashKey; - return this; - } - - public DynamoDbExtensionBuilder rangeKey(String rangeKey) { - this.rangeKey = rangeKey; - return this; - } - - public DynamoDbExtensionBuilder attributeDefinition(AttributeDefinition attributeDefinition) { - attributeDefinitions.add(attributeDefinition); - return this; - } - - public DynamoDbExtensionBuilder globalSecondaryIndex(GlobalSecondaryIndex index) { - globalSecondaryIndexes.add(index); - return this; - } - - public DynamoDbExtensionBuilder localSecondaryIndex(LocalSecondaryIndex index) { - localSecondaryIndexes.add(index); - return this; - } - - public DynamoDbExtension build() { - return new DynamoDbExtension(tableName, hashKey, rangeKey, - attributeDefinitions, globalSecondaryIndexes, localSecondaryIndexes, readCapacityUnits, writeCapacityUnits); - } - } - - public DynamoDbClient getDynamoDbClient() { - return dynamoDB2; - } - - public DynamoDbAsyncClient getDynamoDbAsyncClient() { - return dynamoAsyncDB2; - } - - public AmazonDynamoDB getLegacyDynamoClient() { - return legacyDynamoClient; - } - - public String getTableName() { - return tableName; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java deleted file mode 100644 index de042b9a1..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.security.SecureRandom; -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.CompletableFuture; -import javax.ws.rs.ClientErrorException; -import org.assertj.core.api.Condition; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class IssuedReceiptsManagerTest { - - private static final long NOW_EPOCH_SECONDS = 1_500_000_000L; - private static final String ISSUED_RECEIPTS_TABLE_NAME = "issued_receipts"; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(ISSUED_RECEIPTS_TABLE_NAME) - .hashKey(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID) - .attributeType(ScalarAttributeType.S) - .build()) - .build(); - - ReceiptCredentialRequest receiptCredentialRequest; - IssuedReceiptsManager issuedReceiptsManager; - - @BeforeEach - void beforeEach() { - receiptCredentialRequest = mock(ReceiptCredentialRequest.class); - byte[] generator = new byte[16]; - SECURE_RANDOM.nextBytes(generator); - issuedReceiptsManager = new IssuedReceiptsManager( - ISSUED_RECEIPTS_TABLE_NAME, - Duration.ofDays(90), - dynamoDbExtension.getDynamoDbAsyncClient(), - generator); - } - - @Test - void testRecordIssuance() { - Instant now = Instant.ofEpochSecond(NOW_EPOCH_SECONDS); - byte[] request1 = new byte[20]; - SECURE_RANDOM.nextBytes(request1); - when(receiptCredentialRequest.serialize()).thenReturn(request1); - CompletableFuture future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE, - receiptCredentialRequest, now); - assertThat(future).succeedsWithin(Duration.ofSeconds(3)); - - // same request should succeed - future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE, receiptCredentialRequest, - now); - assertThat(future).succeedsWithin(Duration.ofSeconds(3)); - - // same item with new request should fail - byte[] request2 = new byte[20]; - SECURE_RANDOM.nextBytes(request2); - when(receiptCredentialRequest.serialize()).thenReturn(request2); - future = issuedReceiptsManager.recordIssuance("item-1", SubscriptionProcessor.STRIPE, receiptCredentialRequest, - now); - assertThat(future).failsWithin(Duration.ofSeconds(3)). - withThrowableOfType(Throwable.class). - havingCause(). - isExactlyInstanceOf(ClientErrorException.class). - has(new Condition<>( - e -> e instanceof ClientErrorException && ((ClientErrorException) e).getResponse().getStatus() == 409, - "status 409")); - - // different item with new request should be okay though - future = issuedReceiptsManager.recordIssuance("item-2", SubscriptionProcessor.STRIPE, receiptCredentialRequest, - now); - assertThat(future).succeedsWithin(Duration.ofSeconds(3)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/KeysTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/KeysTest.java deleted file mode 100644 index 4111dacb9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/KeysTest.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.entities.PreKey; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class KeysTest { - - private static final String TABLE_NAME = "Signal_Keys_Test"; - - private Keys keys; - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(TABLE_NAME) - .hashKey(Keys.KEY_ACCOUNT_UUID) - .rangeKey(Keys.KEY_DEVICE_ID_KEY_ID) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(Keys.KEY_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .attributeDefinition( - AttributeDefinition.builder() - .attributeName(Keys.KEY_DEVICE_ID_KEY_ID) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - private static final UUID ACCOUNT_UUID = UUID.randomUUID(); - private static final long DEVICE_ID = 1L; - - @BeforeEach - void setup() { - keys = new Keys(dynamoDbExtension.getDynamoDbClient(), TABLE_NAME); - } - - @Test - void testStore() { - assertEquals(0, keys.getCount(ACCOUNT_UUID, DEVICE_ID), - "Initial pre-key count for an account should be zero"); - - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(new PreKey(1, "public-key"))); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(new PreKey(1, "public-key"))); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID), - "Repeatedly storing same key should have no effect"); - - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(new PreKey(2, "different-public-key"))); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID), - "Inserting a new key should overwrite all prior keys for the given account/device"); - - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(new PreKey(3, "third-public-key"), new PreKey(4, "fourth-public-key"))); - assertEquals(2, keys.getCount(ACCOUNT_UUID, DEVICE_ID), - "Inserting multiple new keys should overwrite all prior keys for the given account/device"); - } - - @Test - void testTakeAccountAndDeviceId() { - assertEquals(Optional.empty(), keys.take(ACCOUNT_UUID, DEVICE_ID)); - - final PreKey preKey = new PreKey(1, "public-key"); - - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(preKey, new PreKey(2, "different-pre-key"))); - assertEquals(Optional.of(preKey), keys.take(ACCOUNT_UUID, DEVICE_ID)); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - } - - @Test - void testGetCount() { - assertEquals(0, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(new PreKey(1, "public-key"))); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - } - - @Test - void testDeleteByAccount() { - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(new PreKey(1, "public-key"), new PreKey(2, "different-public-key"))); - keys.store(ACCOUNT_UUID, DEVICE_ID + 1, List.of(new PreKey(3, "public-key-for-different-device"))); - - assertEquals(2, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID + 1)); - - keys.delete(ACCOUNT_UUID); - - assertEquals(0, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - assertEquals(0, keys.getCount(ACCOUNT_UUID, DEVICE_ID + 1)); - } - - @Test - void testDeleteByAccountAndDevice() { - keys.store(ACCOUNT_UUID, DEVICE_ID, List.of(new PreKey(1, "public-key"), new PreKey(2, "different-public-key"))); - keys.store(ACCOUNT_UUID, DEVICE_ID + 1, List.of(new PreKey(3, "public-key-for-different-device"))); - - assertEquals(2, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID + 1)); - - keys.delete(ACCOUNT_UUID, DEVICE_ID); - - assertEquals(0, keys.getCount(ACCOUNT_UUID, DEVICE_ID)); - assertEquals(1, keys.getCount(ACCOUNT_UUID, DEVICE_ID + 1)); - } - - @Test - void testSortKeyPrefix() { - AttributeValue got = Keys.getSortKeyPrefix(123); - assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 0, 0, 123}, got.b().asByteArray()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWorkTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWorkTest.java deleted file mode 100644 index 21d1cf8be..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ManagedPeriodicWorkTest.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.util.Util; - -class ManagedPeriodicWorkTest { - - private ScheduledExecutorService scheduledExecutorService; - private ManagedPeriodicWorkLock lock; - private TestWork testWork; - - @BeforeEach - void setup() { - scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); - lock = mock(ManagedPeriodicWorkLock.class); - - testWork = new TestWork(lock, Duration.ofMinutes(5), Duration.ofMinutes(5), - scheduledExecutorService); - } - - @AfterEach - void teardown() throws Exception { - scheduledExecutorService.shutdown(); - - assertTrue(scheduledExecutorService.awaitTermination(5, TimeUnit.SECONDS)); - } - - @Test - void test() throws Exception { - when(lock.claimActiveWork(any(), any())).thenReturn(true); - - testWork.start(); - - synchronized (testWork) { - Util.wait(testWork); - } - - testWork.stop(); - - verify(lock, atLeastOnce()).claimActiveWork(anyString(), any(Duration.class)); - verify(lock, atLeastOnce()).releaseActiveWork(anyString()); - - assertTrue(1 <= testWork.getCount()); - } - - @Test - void testSlowWorkShutdown() throws Exception { - - when(lock.claimActiveWork(any(), any())).thenReturn(true); - - testWork.setWorkSleepDuration(Duration.ofSeconds(1)); - - testWork.start(); - - synchronized (testWork) { - Util.wait(testWork); - } - - long startMillis = System.currentTimeMillis(); - - testWork.stop(); - - long runMillis = System.currentTimeMillis() - startMillis; - - assertTrue(runMillis > 500); - - verify(lock, atLeastOnce()).claimActiveWork(anyString(), any(Duration.class)); - verify(lock, atLeastOnce()).releaseActiveWork(anyString()); - - assertTrue(1 <= testWork.getCount()); - } - - @Test - void testWorkExceptionReleasesLock() throws Exception { - when(lock.claimActiveWork(any(), any())).thenReturn(true); - - testWork = new ExceptionalTestWork(lock, Duration.ofMinutes(5), Duration.ofMinutes(5), scheduledExecutorService); - - testWork.setSleepDurationAfterUnexpectedException(Duration.ZERO); - - testWork.start(); - - synchronized (testWork) { - Util.wait(testWork); - } - - testWork.stop(); - - verify(lock, atLeastOnce()).claimActiveWork(anyString(), any(Duration.class)); - verify(lock, atLeastOnce()).releaseActiveWork(anyString()); - - assertEquals(0, testWork.getCount()); - } - - - private static class TestWork extends ManagedPeriodicWork { - - private final AtomicInteger workCounter = new AtomicInteger(); - private Duration workSleepDuration = Duration.ZERO; - - public TestWork(final ManagedPeriodicWorkLock lock, final Duration workerTtl, final Duration runInterval, - final ScheduledExecutorService scheduledExecutorService) { - super(lock, workerTtl, runInterval, scheduledExecutorService); - } - - @Override - protected void doPeriodicWork() throws Exception { - - notifyStarted(); - - if (!workSleepDuration.isZero()) { - Util.sleep(workSleepDuration.toMillis()); - } - - workCounter.incrementAndGet(); - } - - synchronized void notifyStarted() { - notifyAll(); - } - - int getCount() { - return workCounter.get(); - } - - void setWorkSleepDuration(final Duration workSleepDuration) { - this.workSleepDuration = workSleepDuration; - } - } - - private static class ExceptionalTestWork extends TestWork { - - - public ExceptionalTestWork(final ManagedPeriodicWorkLock lock, final Duration workerTtl, final Duration runInterval, - final ScheduledExecutorService scheduledExecutorService) { - super(lock, workerTtl, runInterval, scheduledExecutorService); - } - - @Override - protected void doPeriodicWork() throws Exception { - - notifyStarted(); - - throw new RuntimeException(); - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterIntegrationTest.java deleted file mode 100644 index 9716623e3..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterIntegrationTest.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; -import io.lettuce.core.cluster.SlotHash; -import java.nio.ByteBuffer; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; -import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.ScanRequest; - -class MessagePersisterIntegrationTest { - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = MessagesDynamoDbExtension.build(); - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private ExecutorService notificationExecutorService; - private ExecutorService messageDeletionExecutorService; - private MessagesCache messagesCache; - private MessagesManager messagesManager; - private MessagePersister messagePersister; - private Account account; - - private static final Duration PERSIST_DELAY = Duration.ofMinutes(10); - - @BeforeEach - void setUp() throws Exception { - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> { - connection.sync().flushall(); - connection.sync().upstream().commands().configSet("notify-keyspace-events", "K$glz"); - }); - - @SuppressWarnings("unchecked") final DynamicConfigurationManager dynamicConfigurationManager = - mock(DynamicConfigurationManager.class); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration()); - - messageDeletionExecutorService = Executors.newSingleThreadExecutor(); - final MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(), - dynamoDbExtension.getDynamoDbAsyncClient(), MessagesDynamoDbExtension.TABLE_NAME, Duration.ofDays(14), - messageDeletionExecutorService); - final AccountsManager accountsManager = mock(AccountsManager.class); - - notificationExecutorService = Executors.newSingleThreadExecutor(); - messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), notificationExecutorService, - messageDeletionExecutorService); - messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, mock(ReportMessageManager.class), - messageDeletionExecutorService); - messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, - dynamicConfigurationManager, PERSIST_DELAY); - - account = mock(Account.class); - - final UUID accountUuid = UUID.randomUUID(); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(accountUuid); - when(accountsManager.getByAccountIdentifier(accountUuid)).thenReturn(Optional.of(account)); - - when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration()); - - messagesCache.start(); - } - - @AfterEach - void tearDown() throws Exception { - notificationExecutorService.shutdown(); - notificationExecutorService.awaitTermination(15, TimeUnit.SECONDS); - - messageDeletionExecutorService.shutdown(); - messageDeletionExecutorService.awaitTermination(15, TimeUnit.SECONDS); - } - - @Test - void testScheduledPersistMessages() { - - final int messageCount = 377; - final List expectedMessages = new ArrayList<>(messageCount); - final Instant now = Instant.now(); - - assertTimeoutPreemptively(Duration.ofSeconds(15), () -> { - - for (int i = 0; i < messageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final long timestamp = now.minus(PERSIST_DELAY.multipliedBy(2)).toEpochMilli() + i; - - final MessageProtos.Envelope message = generateRandomMessage(messageGuid, timestamp); - - messagesCache.insert(messageGuid, account.getUuid(), 1, message); - expectedMessages.add(message); - } - - REDIS_CLUSTER_EXTENSION.getRedisCluster() - .useCluster(connection -> connection.sync().set(MessagesCache.NEXT_SLOT_TO_PERSIST_KEY, - String.valueOf(SlotHash.getSlot(MessagesCache.getMessageQueueKey(account.getUuid(), 1)) - 1))); - - final AtomicBoolean messagesPersisted = new AtomicBoolean(false); - - messagesManager.addMessageAvailabilityListener(account.getUuid(), 1, new MessageAvailabilityListener() { - @Override - public boolean handleNewMessagesAvailable() { - return true; - } - - @Override - public boolean handleMessagesPersisted() { - synchronized (messagesPersisted) { - messagesPersisted.set(true); - messagesPersisted.notifyAll(); - return true; - } - } - }); - - messagePersister.start(); - - synchronized (messagesPersisted) { - while (!messagesPersisted.get()) { - messagesPersisted.wait(); - } - } - - messagePersister.stop(); - - DynamoDbClient dynamoDB = dynamoDbExtension.getDynamoDbClient(); - - final List persistedMessages = - dynamoDB.scan(ScanRequest.builder().tableName(MessagesDynamoDbExtension.TABLE_NAME).build()).items().stream() - .map(item -> { - try { - return MessagesDynamoDb.convertItemToEnvelope(item); - } catch (InvalidProtocolBufferException e) { - fail("Could not parse stored message", e); - return null; - } - }) - .toList(); - - assertEquals(expectedMessages, persistedMessages); - }); - } - - private static long extractServerTimestamp(byte[] bytes) { - ByteBuffer bb = ByteBuffer.wrap(bytes); - bb.getLong(); - return bb.getLong(); - } - - private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final long timestamp) { - return MessageProtos.Envelope.newBuilder() - .setTimestamp(timestamp) - .setServerTimestamp(timestamp) - .setContent(ByteString.copyFromUtf8(RandomStringUtils.randomAlphanumeric(256))) - .setType(MessageProtos.Envelope.Type.CIPHERTEXT) - .setServerGuid(messageGuid.toString()) - .setDestinationUuid(UUID.randomUUID().toString()) - .build(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterTest.java deleted file mode 100644 index 0f63fa89f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterTest.java +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.protobuf.ByteString; -import io.lettuce.core.cluster.SlotHash; -import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; - -class MessagePersisterTest { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private ExecutorService sharedExecutorService; - private MessagesCache messagesCache; - private MessagesDynamoDb messagesDynamoDb; - private MessagePersister messagePersister; - private AccountsManager accountsManager; - private MessagesManager messagesManager; - - private static final UUID DESTINATION_ACCOUNT_UUID = UUID.randomUUID(); - private static final String DESTINATION_ACCOUNT_NUMBER = "+18005551234"; - private static final long DESTINATION_DEVICE_ID = 7; - - private static final Duration PERSIST_DELAY = Duration.ofMinutes(5); - - @BeforeEach - void setUp() throws Exception { - - messagesManager = mock(MessagesManager.class); - final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); - - messagesDynamoDb = mock(MessagesDynamoDb.class); - accountsManager = mock(AccountsManager.class); - - final Account account = mock(Account.class); - - when(accountsManager.getByAccountIdentifier(DESTINATION_ACCOUNT_UUID)).thenReturn(Optional.of(account)); - when(account.getNumber()).thenReturn(DESTINATION_ACCOUNT_NUMBER); - when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration()); - - sharedExecutorService = Executors.newSingleThreadExecutor(); - messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), sharedExecutorService, sharedExecutorService); - messagePersister = new MessagePersister(messagesCache, messagesManager, accountsManager, - dynamicConfigurationManager, PERSIST_DELAY); - - doAnswer(invocation -> { - final UUID destinationUuid = invocation.getArgument(0); - final long destinationDeviceId = invocation.getArgument(1); - final List messages = invocation.getArgument(2); - - messagesDynamoDb.store(messages, destinationUuid, destinationDeviceId); - - for (final MessageProtos.Envelope message : messages) { - messagesCache.remove(destinationUuid, destinationDeviceId, UUID.fromString(message.getServerGuid())).get(); - } - - return null; - }).when(messagesManager).persistMessages(any(UUID.class), anyLong(), any()); - } - - @AfterEach - void tearDown() throws Exception { - sharedExecutorService.shutdown(); - sharedExecutorService.awaitTermination(1, TimeUnit.SECONDS); - } - - @Test - void testPersistNextQueuesNoQueues() { - messagePersister.persistNextQueues(Instant.now()); - - verify(accountsManager, never()).getByAccountIdentifier(any(UUID.class)); - } - - @Test - void testPersistNextQueuesSingleQueue() { - final String queueName = new String( - MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID), StandardCharsets.UTF_8); - final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7; - final Instant now = Instant.now(); - - insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount, now); - setNextSlotToPersist(SlotHash.getSlot(queueName)); - - messagePersister.persistNextQueues(now.plus(messagePersister.getPersistDelay())); - - final ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); - - verify(messagesDynamoDb, atLeastOnce()).store(messagesCaptor.capture(), eq(DESTINATION_ACCOUNT_UUID), - eq(DESTINATION_DEVICE_ID)); - assertEquals(messageCount, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum()); - } - - @Test - void testPersistNextQueuesSingleQueueTooSoon() { - final String queueName = new String( - MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID), StandardCharsets.UTF_8); - final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7; - final Instant now = Instant.now(); - - insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount, now); - setNextSlotToPersist(SlotHash.getSlot(queueName)); - - messagePersister.persistNextQueues(now); - - verify(messagesDynamoDb, never()).store(any(), any(), anyLong()); - } - - @Test - void testPersistNextQueuesMultiplePages() { - final int slot = 7; - final int queueCount = (MessagePersister.QUEUE_BATCH_LIMIT * 3) + 7; - final int messagesPerQueue = 10; - final Instant now = Instant.now(); - - for (int i = 0; i < queueCount; i++) { - final String queueName = generateRandomQueueNameForSlot(slot); - final UUID accountUuid = MessagesCache.getAccountUuidFromQueueName(queueName); - final long deviceId = MessagesCache.getDeviceIdFromQueueName(queueName); - final String accountNumber = "+1" + RandomStringUtils.randomNumeric(10); - - final Account account = mock(Account.class); - - when(accountsManager.getByAccountIdentifier(accountUuid)).thenReturn(Optional.of(account)); - when(account.getNumber()).thenReturn(accountNumber); - - insertMessages(accountUuid, deviceId, messagesPerQueue, now); - } - - setNextSlotToPersist(slot); - - messagePersister.persistNextQueues(now.plus(messagePersister.getPersistDelay())); - - final ArgumentCaptor> messagesCaptor = ArgumentCaptor.forClass(List.class); - - verify(messagesDynamoDb, atLeastOnce()).store(messagesCaptor.capture(), any(UUID.class), anyLong()); - assertEquals(queueCount * messagesPerQueue, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum()); - } - - @Test - void testPersistQueueRetry() { - final String queueName = new String( - MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID), StandardCharsets.UTF_8); - final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7; - final Instant now = Instant.now(); - - insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount, now); - setNextSlotToPersist(SlotHash.getSlot(queueName)); - - doAnswer((Answer) invocation -> { - throw new RuntimeException("OH NO."); - }).when(messagesDynamoDb).store(any(), eq(DESTINATION_ACCOUNT_UUID), eq(DESTINATION_DEVICE_ID)); - - messagePersister.persistNextQueues(now.plus(messagePersister.getPersistDelay())); - - assertEquals(List.of(queueName), - messagesCache.getQueuesToPersist(SlotHash.getSlot(queueName), - Instant.now().plus(messagePersister.getPersistDelay()), 1)); - } - - @Test - void testPersistQueueRetryLoop() { - final String queueName = new String( - MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID), StandardCharsets.UTF_8); - final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7; - final Instant now = Instant.now(); - - insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount, now); - setNextSlotToPersist(SlotHash.getSlot(queueName)); - - // returning `0` indicates something not working correctly - when(messagesManager.persistMessages(any(UUID.class), anyLong(), anyList())).thenReturn(0); - - assertTimeoutPreemptively(Duration.ofSeconds(1), () -> - assertThrows(MessagePersistenceException.class, - () -> messagePersister.persistQueue(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))); - } - - @SuppressWarnings("SameParameterValue") - private static String generateRandomQueueNameForSlot(final int slot) { - final UUID uuid = UUID.randomUUID(); - - final String queueNameBase = "user_queue::{" + uuid + "::"; - - for (int deviceId = 0; deviceId < Integer.MAX_VALUE; deviceId++) { - final String queueName = queueNameBase + deviceId + "}"; - - if (SlotHash.getSlot(queueName) == slot) { - return queueName; - } - } - - throw new IllegalStateException("Could not find a queue name for slot " + slot); - } - - private void insertMessages(final UUID accountUuid, final long deviceId, final int messageCount, - final Instant firstMessageTimestamp) { - for (int i = 0; i < messageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - - final MessageProtos.Envelope envelope = MessageProtos.Envelope.newBuilder() - .setTimestamp(firstMessageTimestamp.toEpochMilli() + i) - .setServerTimestamp(firstMessageTimestamp.toEpochMilli() + i) - .setContent(ByteString.copyFromUtf8(RandomStringUtils.randomAlphanumeric(256))) - .setType(MessageProtos.Envelope.Type.CIPHERTEXT) - .setServerGuid(messageGuid.toString()) - .build(); - - messagesCache.insert(messageGuid, accountUuid, deviceId, envelope); - } - } - - private void setNextSlotToPersist(final int nextSlot) { - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster( - connection -> connection.sync().set(MessagesCache.NEXT_SLOT_TO_PERSIST_KEY, String.valueOf(nextSlot - 1))); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheTest.java deleted file mode 100644 index 493f1b829..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheTest.java +++ /dev/null @@ -1,758 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.protobuf.ByteString; -import io.lettuce.core.RedisFuture; -import io.lettuce.core.cluster.SlotHash; -import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands; -import io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands; -import io.lettuce.core.protocol.AsyncCommand; -import io.lettuce.core.protocol.RedisCommand; -import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Deque; -import java.util.List; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.reactivestreams.Publisher; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; -import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper; -import reactor.core.publisher.Flux; -import reactor.core.publisher.FluxSink; -import reactor.test.StepVerifier; - -class MessagesCacheTest { - - private final Random random = new Random(); - private long serialTimestamp = 0; - - @Nested - class WithRealCluster { - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private ExecutorService sharedExecutorService; - private MessagesCache messagesCache; - - private static final UUID DESTINATION_UUID = UUID.randomUUID(); - private static final int DESTINATION_DEVICE_ID = 7; - - @BeforeEach - void setUp() throws Exception { - - REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> { - connection.sync().flushall(); - connection.sync().upstream().commands().configSet("notify-keyspace-events", "K$glz"); - }); - - sharedExecutorService = Executors.newSingleThreadExecutor(); - messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), sharedExecutorService, - sharedExecutorService); - - messagesCache.start(); - } - - @AfterEach - void tearDown() throws Exception { - messagesCache.stop(); - - sharedExecutorService.shutdown(); - sharedExecutorService.awaitTermination(1, TimeUnit.SECONDS); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testInsert(final boolean sealedSender) { - final UUID messageGuid = UUID.randomUUID(); - assertTrue(messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, - generateRandomMessage(messageGuid, sealedSender)) > 0); - } - - @Test - void testDoubleInsertGuid() { - final UUID duplicateGuid = UUID.randomUUID(); - final MessageProtos.Envelope duplicateMessage = generateRandomMessage(duplicateGuid, false); - - final long firstId = messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, - duplicateMessage); - final long secondId = messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, - duplicateMessage); - - assertEquals(firstId, secondId); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testRemoveByUUID(final boolean sealedSender) throws Exception { - final UUID messageGuid = UUID.randomUUID(); - - assertEquals(Optional.empty(), - messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageGuid).get(5, TimeUnit.SECONDS)); - - final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender); - - messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message); - final Optional maybeRemovedMessage = messagesCache.remove(DESTINATION_UUID, - DESTINATION_DEVICE_ID, messageGuid).get(5, TimeUnit.SECONDS); - - assertEquals(Optional.of(message), maybeRemovedMessage); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testRemoveBatchByUUID(final boolean sealedSender) throws Exception { - final int messageCount = 10; - - final List messagesToRemove = new ArrayList<>(messageCount); - final List messagesToPreserve = new ArrayList<>(messageCount); - - for (int i = 0; i < 10; i++) { - messagesToRemove.add(generateRandomMessage(UUID.randomUUID(), sealedSender)); - messagesToPreserve.add(generateRandomMessage(UUID.randomUUID(), sealedSender)); - } - - assertEquals(Collections.emptyList(), messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, - messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid())) - .collect(Collectors.toList())).get(5, TimeUnit.SECONDS)); - - for (final MessageProtos.Envelope message : messagesToRemove) { - messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID, - message); - } - - for (final MessageProtos.Envelope message : messagesToPreserve) { - messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID, - message); - } - - final List removedMessages = messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, - messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid())) - .collect(Collectors.toList())).get(5, TimeUnit.SECONDS); - - assertEquals(messagesToRemove, removedMessages); - assertEquals(messagesToPreserve, - messagesCache.getMessagesToPersist(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount)); - } - - @Test - void testHasMessages() { - assertFalse(messagesCache.hasMessages(DESTINATION_UUID, DESTINATION_DEVICE_ID)); - - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope message = generateRandomMessage(messageGuid, true); - messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message); - - assertTrue(messagesCache.hasMessages(DESTINATION_UUID, DESTINATION_DEVICE_ID)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testGetMessages(final boolean sealedSender) throws Exception { - final int messageCount = 100; - - final List expectedMessages = new ArrayList<>(messageCount); - - for (int i = 0; i < messageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender); - messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message); - - expectedMessages.add(message); - } - - assertEquals(expectedMessages, get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount)); - - messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, - expectedMessages.stream() - .map(MessageProtos.Envelope::getServerGuid) - .map(UUID::fromString) - .collect(Collectors.toList())); - - final UUID message1Guid = UUID.randomUUID(); - final MessageProtos.Envelope message1 = generateRandomMessage(message1Guid, sealedSender); - messagesCache.insert(message1Guid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message1); - final List get1 = get(DESTINATION_UUID, DESTINATION_DEVICE_ID, - 1); - assertEquals(List.of(message1), get1); - - messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, message1Guid).get(5, TimeUnit.SECONDS); - - final UUID message2Guid = UUID.randomUUID(); - final MessageProtos.Envelope message2 = generateRandomMessage(message2Guid, sealedSender); - - messagesCache.insert(message2Guid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message2); - - assertEquals(List.of(message2), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, 1)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testGetMessagesPublisher(final boolean expectStale) throws Exception { - final int messageCount = 214; - - final List expectedMessages = new ArrayList<>(messageCount); - - for (int i = 0; i < messageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope message = generateRandomMessage(messageGuid, true); - messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message); - - expectedMessages.add(message); - } - - final UUID ephemeralMessageGuid = UUID.randomUUID(); - final MessageProtos.Envelope ephemeralMessage = generateRandomMessage(ephemeralMessageGuid, true) - .toBuilder().setEphemeral(true).build(); - messagesCache.insert(ephemeralMessageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, ephemeralMessage); - - final Clock cacheClock; - if (expectStale) { - cacheClock = Clock.fixed(Instant.ofEpochMilli(serialTimestamp + 1), - ZoneId.of("Etc/UTC")); - } else { - cacheClock = Clock.fixed( - Instant.ofEpochMilli(serialTimestamp + 1).plus(MessagesCache.MAX_EPHEMERAL_MESSAGE_DELAY), - ZoneId.of("Etc/UTC")); - } - - final MessagesCache messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - REDIS_CLUSTER_EXTENSION.getRedisCluster(), - cacheClock, - sharedExecutorService, - sharedExecutorService); - - final List actualMessages = Flux.from( - messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID)) - .collectList() - .block(Duration.ofSeconds(5)); - - if (expectStale) { - final List expectedAllMessages = new ArrayList<>() {{ - addAll(expectedMessages); - add(ephemeralMessage); - }}; - - assertEquals(expectedAllMessages, actualMessages); - - } else { - assertEquals(expectedMessages, actualMessages); - - // delete all of these messages and call `getAll()`, to confirm that ephemeral messages have been discarded - CompletableFuture.allOf(actualMessages.stream() - .map(message -> messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, - UUID.fromString(message.getServerGuid()))) - .toArray(CompletableFuture[]::new)) - .get(5, TimeUnit.SECONDS); - - final List messages = messagesCache.getAllMessages(DESTINATION_UUID, - DESTINATION_DEVICE_ID) - .collectList() - .toFuture().get(5, TimeUnit.SECONDS); - - assertTrue(messages.isEmpty()); - } - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testClearQueueForDevice(final boolean sealedSender) { - final int messageCount = 100; - - for (final int deviceId : new int[]{DESTINATION_DEVICE_ID, DESTINATION_DEVICE_ID + 1}) { - for (int i = 0; i < messageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender); - - messagesCache.insert(messageGuid, DESTINATION_UUID, deviceId, message); - } - } - - messagesCache.clear(DESTINATION_UUID, DESTINATION_DEVICE_ID); - - assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount)); - assertEquals(messageCount, get(DESTINATION_UUID, DESTINATION_DEVICE_ID + 1, messageCount).size()); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testClearQueueForAccount(final boolean sealedSender) { - final int messageCount = 100; - - for (final int deviceId : new int[]{DESTINATION_DEVICE_ID, DESTINATION_DEVICE_ID + 1}) { - for (int i = 0; i < messageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender); - - messagesCache.insert(messageGuid, DESTINATION_UUID, deviceId, message); - } - } - - messagesCache.clear(DESTINATION_UUID); - - assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount)); - assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID + 1, messageCount)); - } - - @Test - void testClearNullUuid() { - // We're happy as long as this doesn't throw an exception - messagesCache.clear(null); - } - - @Test - void testGetAccountFromQueueName() { - assertEquals(DESTINATION_UUID, - MessagesCache.getAccountUuidFromQueueName( - new String(MessagesCache.getMessageQueueKey(DESTINATION_UUID, DESTINATION_DEVICE_ID), - StandardCharsets.UTF_8))); - } - - @Test - void testGetDeviceIdFromQueueName() { - assertEquals(DESTINATION_DEVICE_ID, - MessagesCache.getDeviceIdFromQueueName( - new String(MessagesCache.getMessageQueueKey(DESTINATION_UUID, DESTINATION_DEVICE_ID), - StandardCharsets.UTF_8))); - } - - @Test - void testGetQueueNameFromKeyspaceChannel() { - assertEquals("1b363a31-a429-4fb6-8959-984a025e72ff::7", - MessagesCache.getQueueNameFromKeyspaceChannel( - "__keyspace@0__:user_queue::{1b363a31-a429-4fb6-8959-984a025e72ff::7}")); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - public void testGetQueuesToPersist(final boolean sealedSender) { - final UUID messageGuid = UUID.randomUUID(); - - messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, - generateRandomMessage(messageGuid, sealedSender)); - final int slot = SlotHash.getSlot(DESTINATION_UUID + "::" + DESTINATION_DEVICE_ID); - - assertTrue(messagesCache.getQueuesToPersist(slot + 1, Instant.now().plusSeconds(60), 100).isEmpty()); - - final List queues = messagesCache.getQueuesToPersist(slot, Instant.now().plusSeconds(60), 100); - - assertEquals(1, queues.size()); - assertEquals(DESTINATION_UUID, MessagesCache.getAccountUuidFromQueueName(queues.get(0))); - assertEquals(DESTINATION_DEVICE_ID, MessagesCache.getDeviceIdFromQueueName(queues.get(0))); - } - - @Test - void testNotifyListenerNewMessage() { - final AtomicBoolean notified = new AtomicBoolean(false); - final UUID messageGuid = UUID.randomUUID(); - - final MessageAvailabilityListener listener = new MessageAvailabilityListener() { - @Override - public boolean handleNewMessagesAvailable() { - synchronized (notified) { - notified.set(true); - notified.notifyAll(); - - return true; - } - } - - @Override - public boolean handleMessagesPersisted() { - return true; - } - }; - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - messagesCache.addMessageAvailabilityListener(DESTINATION_UUID, DESTINATION_DEVICE_ID, listener); - messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, - generateRandomMessage(messageGuid, true)); - - synchronized (notified) { - while (!notified.get()) { - notified.wait(); - } - } - - assertTrue(notified.get()); - }); - } - - @Test - void testNotifyListenerPersisted() { - final AtomicBoolean notified = new AtomicBoolean(false); - - final MessageAvailabilityListener listener = new MessageAvailabilityListener() { - @Override - public boolean handleNewMessagesAvailable() { - return true; - } - - @Override - public boolean handleMessagesPersisted() { - synchronized (notified) { - notified.set(true); - notified.notifyAll(); - - return true; - } - } - }; - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - messagesCache.addMessageAvailabilityListener(DESTINATION_UUID, DESTINATION_DEVICE_ID, listener); - - messagesCache.lockQueueForPersistence(DESTINATION_UUID, DESTINATION_DEVICE_ID); - messagesCache.unlockQueueForPersistence(DESTINATION_UUID, DESTINATION_DEVICE_ID); - - synchronized (notified) { - while (!notified.get()) { - notified.wait(); - } - } - - assertTrue(notified.get()); - }); - } - - - /** - * Helper class that implements {@link MessageAvailabilityListener#handleNewMessagesAvailable()} by always returning - * {@code false}. Its {@code counter} field tracks how many times {@code handleNewMessagesAvailable} has been - * called. - *

- * It uses a {@link CompletableFuture} to signal that it has received a “messages available” callback for the first - * time. - */ - private static class NewMessagesAvailabilityClosedListener implements MessageAvailabilityListener { - - private int counter; - - private final Consumer messageHandledCallback; - private final CompletableFuture firstMessageHandled = new CompletableFuture<>(); - - private NewMessagesAvailabilityClosedListener(final Consumer messageHandledCallback) { - this.messageHandledCallback = messageHandledCallback; - } - - @Override - public boolean handleNewMessagesAvailable() { - counter++; - messageHandledCallback.accept(counter); - firstMessageHandled.complete(null); - - return false; - - } - - @Override - public boolean handleMessagesPersisted() { - return true; - } - } - - @Test - void testAvailabilityListenerResponses() { - final NewMessagesAvailabilityClosedListener listener1 = new NewMessagesAvailabilityClosedListener( - count -> assertEquals(1, count)); - final NewMessagesAvailabilityClosedListener listener2 = new NewMessagesAvailabilityClosedListener( - count -> assertEquals(1, count)); - - assertTimeoutPreemptively(Duration.ofSeconds(30), () -> { - messagesCache.addMessageAvailabilityListener(DESTINATION_UUID, DESTINATION_DEVICE_ID, listener1); - final UUID messageGuid1 = UUID.randomUUID(); - messagesCache.insert(messageGuid1, DESTINATION_UUID, DESTINATION_DEVICE_ID, - generateRandomMessage(messageGuid1, true)); - - listener1.firstMessageHandled.get(); - - // Avoid a race condition by blocking on the message handled future *and* the current notification executor task— - // the notification executor task includes unsubscribing `listener1`, and, if we don’t wait, sometimes - // `listener2` will get subscribed before `listener1` is cleaned up - sharedExecutorService.submit(() -> listener1.firstMessageHandled.get()).get(); - - final UUID messageGuid2 = UUID.randomUUID(); - messagesCache.insert(messageGuid2, DESTINATION_UUID, DESTINATION_DEVICE_ID, - generateRandomMessage(messageGuid2, true)); - - messagesCache.addMessageAvailabilityListener(DESTINATION_UUID, DESTINATION_DEVICE_ID, listener2); - - final UUID messageGuid3 = UUID.randomUUID(); - messagesCache.insert(messageGuid3, DESTINATION_UUID, DESTINATION_DEVICE_ID, - generateRandomMessage(messageGuid3, true)); - - listener2.firstMessageHandled.get(); - }); - } - - private List get(final UUID destinationUuid, final long destinationDeviceId, - final int messageCount) { - return Flux.from(messagesCache.get(destinationUuid, destinationDeviceId)) - .take(messageCount, true) - .collectList() - .block(); - } - - } - - @Nested - class WithMockCluster { - - private MessagesCache messagesCache; - private RedisAdvancedClusterReactiveCommands reactiveCommands; - private RedisAdvancedClusterAsyncCommands asyncCommands; - - @SuppressWarnings("unchecked") - @BeforeEach - void setup() throws Exception { - reactiveCommands = mock(RedisAdvancedClusterReactiveCommands.class); - asyncCommands = mock(RedisAdvancedClusterAsyncCommands.class); - - final FaultTolerantRedisCluster mockCluster = RedisClusterHelper.builder() - .binaryReactiveCommands(reactiveCommands) - .binaryAsyncCommands(asyncCommands) - .build(); - - messagesCache = new MessagesCache(mockCluster, mockCluster, Clock.systemUTC(), mock(ExecutorService.class), - Executors.newSingleThreadExecutor()); - } - - @AfterEach - void teardown() { - StepVerifier.resetDefaultTimeout(); - } - - @Test - @Disabled("flaky test") - void testGetAllMessagesLimitsAndBackpressure() { - // this test makes sure that we don’t fetch and buffer all messages from the cache when the publisher - // is subscribed. Rather, we should be fetching in pages to satisfy downstream requests, so that memory usage - // is limited to few pages of messages - - // we use a combination of Flux.just() and Sinks to control when data is “fetched” from the cache. The initial - // Flux.just()s are pages that are readily available, on demand. By design, there are more of these pages than - // the initial prefetch. The sinks allow us to create extra demand but defer producing values to satisfy the demand - // until later on. - - final AtomicReference> page4Sink = new AtomicReference<>(); - final AtomicReference> page56Sink = new AtomicReference<>(); - final AtomicReference> emptyFinalPageSink = new AtomicReference<>(); - - final Deque> pages = new ArrayDeque<>(); - pages.add(generatePage()); - pages.add(generatePage()); - pages.add(generatePage()); - pages.add(generatePage()); - // make sure that stale ephemeral messages are also produced by calls to getAllMessages() - pages.add(generateStaleEphemeralPage()); - pages.add(generatePage()); - - when(reactiveCommands.evalsha(any(), any(), any(), any())) - .thenReturn(Flux.just(pages.pop())) - .thenReturn(Flux.just(pages.pop())) - .thenReturn(Flux.just(pages.pop())) - .thenReturn(Flux.create(sink -> page4Sink.compareAndSet(null, sink))) - .thenReturn(Flux.create(sink -> page56Sink.compareAndSet(null, sink))) - .thenReturn(Flux.create(sink -> emptyFinalPageSink.compareAndSet(null, sink))) - .thenReturn(Flux.empty()); - - final Flux allMessages = messagesCache.getAllMessages(UUID.randomUUID(), 1L); - - // Why initialValue = 3? - // 1. messagesCache.getAllMessages() above produces the first call - // 2. when we subscribe, the prefetch of 1 results in `expand()`, which produces a second call - // 3. there is an implicit “low tide mark” of 1, meaning there will be an extra call to replenish when there is - // 1 value remaining - final AtomicInteger expectedReactiveCommandInvocations = new AtomicInteger(3); - - StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); - - final int page = 100; - final int halfPage = page / 2; - - // in order to fully control demand and separate the prefetch mechanics, initially subscribe with a request of 0 - StepVerifier.create(allMessages, 0) - .expectSubscription() - .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.get())).evalsha(any(), any(), - any(), any())) - .thenRequest(halfPage) // page 0.5 requested - .expectNextCount(halfPage) // page 0.5 produced - // page 0.5 produced, 1.5 remain, so no additional interactions with the cache cluster - .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.get())).evalsha(any(), - any(), any(), any())) - .then(() -> assertNull(page4Sink.get(), "page 4 should not have been fetched yet")) - .thenRequest(page) // page 1.5 requested - .expectNextCount(page) // page 1.5 produced - - // we now have produced 1.5 pages, have 0.5 buffered, and two more have been prefetched. - // after producing more than a full page, we’ll need to replenish from the cache. - // future requests will depend on sink emitters. - // also NB: times() checks cumulative calls, hence addAndGet - .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(1))).evalsha(any(), - any(), any(), any())) - .then(() -> assertNotNull(page4Sink.get(), "page 4 should have been fetched")) - .thenRequest(page + halfPage) // page 3 requested - .expectNextCount(page + halfPage) // page 1.5–3 produced - - .thenRequest(halfPage) // page 3.5 requested - .then(() -> assertNull(page56Sink.get(), "page 5 should not have been fetched yet")) - .then(() -> page4Sink.get().next(pages.pop()).complete()) - .expectNextCount(halfPage) // page 3.5 produced - .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(1))).evalsha(any(), - any(), any(), any())) - .then(() -> assertNotNull(page56Sink.get(), "page 5 should have been fetched")) - - .thenRequest(page) // page 4.5 requested - .expectNextCount(halfPage) // page 4 produced - - .thenRequest(page * 4) // request more demand than we will ultimately satisfy - - .then(() -> page56Sink.get().next(pages.pop()).next(pages.pop()).complete()) - .expectNextCount(page + page) // page 5 and 6 produced - .then(() -> emptyFinalPageSink.get().complete()) - // confirm that cache calls increased by 2: one for page 5-and-6 (we got a two-fer in next(pop()).next(pop()), - // and one for the final, empty page - .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(2))).evalsha(any(), - any(), any(), - any())) - .expectComplete() - .log() - .verify(); - - // make sure that we consumed all the pages, especially in case of refactoring - assertTrue(pages.isEmpty()); - } - - @Test - void testGetDiscardsEphemeralMessages() { - final Deque> pages = new ArrayDeque<>(); - pages.add(generatePage()); - pages.add(generatePage()); - pages.add(generateStaleEphemeralPage()); - - when(reactiveCommands.evalsha(any(), any(), any(), any())) - .thenReturn(Flux.just(pages.pop())) - .thenReturn(Flux.just(pages.pop())) - .thenReturn(Flux.just(pages.pop())) - .thenReturn(Flux.empty()); - - final AsyncCommand removeSuccess = new AsyncCommand<>(mock(RedisCommand.class)); - removeSuccess.complete(); - - when(asyncCommands.evalsha(any(), any(), any(), any())) - .thenReturn((RedisFuture) removeSuccess); - - final Publisher allMessages = messagesCache.get(UUID.randomUUID(), 1L); - - StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); - - // async commands are used for remove(), and nothing should happen until we are subscribed - verify(asyncCommands, never()).evalsha(any(), any(), any(byte[][].class), any(byte[].class)); - // the reactive commands will be called once, to prep the first page fetch (but no remote request would actually be sent) - verify(reactiveCommands, times(1)).evalsha(any(), any(), any(byte[][].class), any(byte[].class)); - - StepVerifier.create(allMessages) - .expectSubscription() - .expectNextCount(200) - .expectComplete() - .log() - .verify(); - - assertTrue(pages.isEmpty()); - verify(asyncCommands, atLeast(1)).evalsha(any(), any(), any(), any()); - } - - private List generatePage() { - final List messagesAndIds = new ArrayList<>(); - - for (int i = 0; i < 100; i++) { - final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID(), true); - messagesAndIds.add(envelope.toByteArray()); - messagesAndIds.add(String.valueOf(serialTimestamp).getBytes()); - } - - return messagesAndIds; - } - - private List generateStaleEphemeralPage() { - final List messagesAndIds = new ArrayList<>(); - - for (int i = 0; i < 100; i++) { - final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID(), true) - .toBuilder().setEphemeral(true).build(); - messagesAndIds.add(envelope.toByteArray()); - messagesAndIds.add(String.valueOf(serialTimestamp).getBytes()); - } - - return messagesAndIds; - } - } - - private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final boolean sealedSender) { - return generateRandomMessage(messageGuid, sealedSender, serialTimestamp++); - } - - private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final boolean sealedSender, - final long timestamp) { - final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder() - .setTimestamp(timestamp) - .setServerTimestamp(timestamp) - .setContent(ByteString.copyFromUtf8(RandomStringUtils.randomAlphanumeric(256))) - .setType(MessageProtos.Envelope.Type.CIPHERTEXT) - .setServerGuid(messageGuid.toString()) - .setDestinationUuid(UUID.randomUUID().toString()); - - if (!sealedSender) { - envelopeBuilder.setSourceDevice(random.nextInt(256)) - .setSourceUuid(UUID.randomUUID().toString()); - } - - return envelopeBuilder.build(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDbTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDbTest.java deleted file mode 100644 index 7d76b1069..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDbTest.java +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.google.protobuf.ByteString; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.reactivestreams.Publisher; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.tests.util.MessageHelper; -import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension; -import reactor.core.publisher.Flux; -import reactor.test.StepVerifier; - -class MessagesDynamoDbTest { - - - private static final Random random = new Random(); - private static final MessageProtos.Envelope MESSAGE1; - private static final MessageProtos.Envelope MESSAGE2; - private static final MessageProtos.Envelope MESSAGE3; - - static { - final long serverTimestamp = System.currentTimeMillis(); - MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder(); - builder.setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER); - builder.setTimestamp(123456789L); - builder.setContent(ByteString.copyFrom(new byte[]{(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF})); - builder.setServerGuid(UUID.randomUUID().toString()); - builder.setServerTimestamp(serverTimestamp); - builder.setDestinationUuid(UUID.randomUUID().toString()); - - MESSAGE1 = builder.build(); - - builder.setType(MessageProtos.Envelope.Type.CIPHERTEXT); - builder.setSourceUuid(UUID.randomUUID().toString()); - builder.setSourceDevice(1); - builder.setContent(ByteString.copyFromUtf8("MOO")); - builder.setServerGuid(UUID.randomUUID().toString()); - builder.setServerTimestamp(serverTimestamp + 1); - builder.setDestinationUuid(UUID.randomUUID().toString()); - - MESSAGE2 = builder.build(); - - builder.setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER); - builder.clearSourceUuid(); - builder.clearSourceDevice(); - builder.setContent(ByteString.copyFromUtf8("COW")); - builder.setServerGuid(UUID.randomUUID().toString()); - builder.setServerTimestamp(serverTimestamp); // Test same millisecond arrival for two different messages - builder.setDestinationUuid(UUID.randomUUID().toString()); - - MESSAGE3 = builder.build(); - } - - private ExecutorService messageDeletionExecutorService; - private MessagesDynamoDb messagesDynamoDb; - - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = MessagesDynamoDbExtension.build(); - - @BeforeEach - void setup() { - messageDeletionExecutorService = Executors.newSingleThreadExecutor(); - messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(), - dynamoDbExtension.getDynamoDbAsyncClient(), MessagesDynamoDbExtension.TABLE_NAME, Duration.ofDays(14), - messageDeletionExecutorService); - } - - @AfterEach - void teardown() throws Exception { - messageDeletionExecutorService.shutdown(); - messageDeletionExecutorService.awaitTermination(5, TimeUnit.SECONDS); - - StepVerifier.resetDefaultTimeout(); - } - - @Test - void testSimpleFetchAfterInsert() { - final UUID destinationUuid = UUID.randomUUID(); - final int destinationDeviceId = random.nextInt(255) + 1; - messagesDynamoDb.store(List.of(MESSAGE1, MESSAGE2, MESSAGE3), destinationUuid, destinationDeviceId); - - final List messagesStored = load(destinationUuid, destinationDeviceId, - MessagesDynamoDb.RESULT_SET_CHUNK_SIZE); - assertThat(messagesStored).isNotNull().hasSize(3); - final MessageProtos.Envelope firstMessage = - MESSAGE1.getServerGuid().compareTo(MESSAGE3.getServerGuid()) < 0 ? MESSAGE1 : MESSAGE3; - final MessageProtos.Envelope secondMessage = firstMessage == MESSAGE1 ? MESSAGE3 : MESSAGE1; - assertThat(messagesStored).element(0).isEqualTo(firstMessage); - assertThat(messagesStored).element(1).isEqualTo(secondMessage); - assertThat(messagesStored).element(2).isEqualTo(MESSAGE2); - } - - @ParameterizedTest - @ValueSource(ints = {10, 100, 100, 1_000, 3_000}) - void testLoadManyAfterInsert(final int messageCount) { - final UUID destinationUuid = UUID.randomUUID(); - final int destinationDeviceId = random.nextInt(255) + 1; - - final List messages = new ArrayList<>(messageCount); - for (int i = 0; i < messageCount; i++) { - messages.add(MessageHelper.createMessage(UUID.randomUUID(), 1, destinationUuid, (i + 1L) * 1000, "message " + i)); - } - - messagesDynamoDb.store(messages, destinationUuid, destinationDeviceId); - - final Publisher fetchedMessages = messagesDynamoDb.load(destinationUuid, destinationDeviceId, null); - - final long firstRequest = Math.min(10, messageCount); - StepVerifier.setDefaultTimeout(Duration.ofSeconds(15)); - - StepVerifier.Step step = StepVerifier.create(fetchedMessages, 0) - .expectSubscription() - .thenRequest(firstRequest) - .expectNextCount(firstRequest); - - if (messageCount > firstRequest) { - step = step.thenRequest(messageCount) - .expectNextCount(messageCount - firstRequest); - } - - step.thenCancel() - .verify(); - } - - @Test - void testLimitedLoad() { - final int messageCount = 200; - final UUID destinationUuid = UUID.randomUUID(); - final int destinationDeviceId = random.nextInt(255) + 1; - - final List messages = new ArrayList<>(messageCount); - for (int i = 0; i < messageCount; i++) { - messages.add(MessageHelper.createMessage(UUID.randomUUID(), 1, destinationUuid, (i + 1L) * 1000, "message " + i)); - } - - messagesDynamoDb.store(messages, destinationUuid, destinationDeviceId); - - final int messageLoadLimit = 100; - final int halfOfMessageLoadLimit = messageLoadLimit / 2; - final Publisher fetchedMessages = messagesDynamoDb.load(destinationUuid, destinationDeviceId, messageLoadLimit); - - StepVerifier.setDefaultTimeout(Duration.ofSeconds(10)); - - final AtomicInteger messagesRemaining = new AtomicInteger(messageLoadLimit); - - StepVerifier.create(fetchedMessages, 0) - .expectSubscription() - .thenRequest(halfOfMessageLoadLimit) - .expectNextCount(halfOfMessageLoadLimit) - // the first 100 should be fetched and buffered, but further requests should fail - .then(() -> dynamoDbExtension.stopServer()) - .thenRequest(halfOfMessageLoadLimit) - .expectNextCount(halfOfMessageLoadLimit) - // we’ve consumed all the buffered messages, so a single request will fail - .thenRequest(1) - .expectError() - .verify(); - } - - @Test - void testDeleteForDestination() { - final UUID destinationUuid = UUID.randomUUID(); - final UUID secondDestinationUuid = UUID.randomUUID(); - messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE1); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE3); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .hasSize(1).element(0).isEqualTo(MESSAGE2); - - messagesDynamoDb.deleteAllMessagesForAccount(destinationUuid); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty(); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty(); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .hasSize(1).element(0).isEqualTo(MESSAGE2); - } - - @Test - void testDeleteForDestinationDevice() { - final UUID destinationUuid = UUID.randomUUID(); - final UUID secondDestinationUuid = UUID.randomUUID(); - messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE1); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE3); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .hasSize(1).element(0).isEqualTo(MESSAGE2); - - messagesDynamoDb.deleteAllMessagesForDevice(destinationUuid, 2); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE1); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().isEmpty(); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .hasSize(1).element(0).isEqualTo(MESSAGE2); - } - - @Test - void testDeleteMessageByDestinationAndGuid() throws Exception { - final UUID destinationUuid = UUID.randomUUID(); - final UUID secondDestinationUuid = UUID.randomUUID(); - messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE1); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE3); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .hasSize(1).element(0).isEqualTo(MESSAGE2); - - final Optional deletedMessage = messagesDynamoDb.deleteMessageByDestinationAndGuid( - secondDestinationUuid, - UUID.fromString(MESSAGE2.getServerGuid())).get(5, TimeUnit.SECONDS); - - assertThat(deletedMessage).isPresent(); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE1); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE3); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .isEmpty(); - - final Optional alreadyDeletedMessage = messagesDynamoDb.deleteMessageByDestinationAndGuid( - secondDestinationUuid, - UUID.fromString(MESSAGE2.getServerGuid())).get(5, TimeUnit.SECONDS); - - assertThat(alreadyDeletedMessage).isNotPresent(); - - } - - @Test - void testDeleteSingleMessage() throws Exception { - final UUID destinationUuid = UUID.randomUUID(); - final UUID secondDestinationUuid = UUID.randomUUID(); - messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, 1); - messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, 2); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE1); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE3); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .hasSize(1).element(0).isEqualTo(MESSAGE2); - - messagesDynamoDb.deleteMessage(secondDestinationUuid, 1, - UUID.fromString(MESSAGE2.getServerGuid()), MESSAGE2.getServerTimestamp()).get(1, TimeUnit.SECONDS); - - assertThat(load(destinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE1); - assertThat(load(destinationUuid, 2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1) - .element(0).isEqualTo(MESSAGE3); - assertThat(load(secondDestinationUuid, 1, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull() - .isEmpty(); - } - - private List load(final UUID destinationUuid, final long destinationDeviceId, - final int count) { - return Flux.from(messagesDynamoDb.load(destinationUuid, destinationDeviceId, count)) - .take(count, true) - .collectList() - .block(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java deleted file mode 100644 index 5c7f31af9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; - -import java.util.UUID; -import java.util.concurrent.Executors; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; - -class MessagesManagerTest { - - private final MessagesDynamoDb messagesDynamoDb = mock(MessagesDynamoDb.class); - private final MessagesCache messagesCache = mock(MessagesCache.class); - private final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class); - - private final MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, - reportMessageManager, Executors.newSingleThreadExecutor()); - - @Test - void insert() { - final UUID sourceAci = UUID.randomUUID(); - final Envelope message = Envelope.newBuilder() - .setSourceUuid(sourceAci.toString()) - .build(); - - final UUID destinationUuid = UUID.randomUUID(); - - messagesManager.insert(destinationUuid, 1L, message); - - verify(reportMessageManager).store(eq(sourceAci.toString()), any(UUID.class)); - - final Envelope syncMessage = Envelope.newBuilder(message) - .setSourceUuid(destinationUuid.toString()) - .build(); - - messagesManager.insert(destinationUuid, 1L, syncMessage); - - verifyNoMoreInteractions(reportMessageManager); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/NonNormalizedAccountCrawlerListenerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/NonNormalizedAccountCrawlerListenerTest.java deleted file mode 100644 index 2f2770026..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/NonNormalizedAccountCrawlerListenerTest.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.UUID; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class NonNormalizedAccountCrawlerListenerTest { - - @ParameterizedTest - @MethodSource - void hasNumberNormalized(final String number, final boolean expectNormalized) { - final Account account = mock(Account.class); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(account.getNumber()).thenReturn(number); - - assertEquals(expectNormalized, NonNormalizedAccountCrawlerListener.hasNumberNormalized(account)); - } - - private static Stream hasNumberNormalized() { - return Stream.of( - Arguments.of("+447700900111", true), - Arguments.of("+4407700900111", false), - Arguments.of("Not a real phone number", false), - Arguments.of(null, false) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java deleted file mode 100644 index edb858ac9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; - -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.Projection; -import software.amazon.awssdk.services.dynamodb.model.ProjectionType; -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class PhoneNumberIdentifiersTest { - - private static final String PNI_TABLE_NAME = "pni_test"; - - @RegisterExtension - static DynamoDbExtension DYNAMO_DB_EXTENSION = DynamoDbExtension.builder() - .tableName(PNI_TABLE_NAME) - .hashKey(PhoneNumberIdentifiers.KEY_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(PhoneNumberIdentifiers.KEY_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(PhoneNumberIdentifiers.ATTR_PHONE_NUMBER_IDENTIFIER) - .attributeType(ScalarAttributeType.B) - .build()) - .globalSecondaryIndex(GlobalSecondaryIndex.builder() - .indexName(PhoneNumberIdentifiers.INDEX_NAME) - .projection(Projection.builder() - .projectionType(ProjectionType.KEYS_ONLY) - .build()) - .keySchema(KeySchemaElement.builder().keyType(KeyType.HASH) - .attributeName(PhoneNumberIdentifiers.ATTR_PHONE_NUMBER_IDENTIFIER) - .build()) - .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) - .build()) - .build(); - - private PhoneNumberIdentifiers phoneNumberIdentifiers; - - @BeforeEach - void setUp() { - phoneNumberIdentifiers = new PhoneNumberIdentifiers(DYNAMO_DB_EXTENSION.getDynamoDbClient(), PNI_TABLE_NAME); - } - - @Test - void getPhoneNumberIdentifier() { - final String number = "+18005551234"; - final String differentNumber = "+18005556789"; - - final UUID firstPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number); - final UUID secondPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number); - - assertEquals(firstPni, secondPni); - assertNotEquals(firstPni, phoneNumberIdentifiers.getPhoneNumberIdentifier(differentNumber)); - } - - @Test - void generatePhoneNumberIdentifierIfNotExists() { - final String number = "+18005551234"; - - assertEquals(phoneNumberIdentifiers.generatePhoneNumberIdentifierIfNotExists(number), - phoneNumberIdentifiers.generatePhoneNumberIdentifierIfNotExists(number)); - } - - @Test - void getPhoneNumber() { - final String number = "+18005551234"; - - assertFalse(phoneNumberIdentifiers.getPhoneNumber(UUID.randomUUID()).isPresent()); - - final UUID pni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number); - assertEquals(Optional.of(number), phoneNumberIdentifiers.getPhoneNumber(pni)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java deleted file mode 100644 index 328a24211..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java +++ /dev/null @@ -1,302 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.nio.charset.StandardCharsets; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Stream; - -public abstract class ProfilesTest { - - private static final String PROFILES_TABLE_NAME = "profiles_test"; - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(PROFILES_TABLE_NAME) - .hashKey(Profiles.KEY_ACCOUNT_UUID) - .rangeKey(Profiles.ATTR_VERSION) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(Profiles.KEY_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(Profiles.ATTR_VERSION) - .attributeType(ScalarAttributeType.S) - .build()) - .build(); - - private Profiles profiles; - - @BeforeEach - void setUp() { - profiles = new Profiles(dynamoDbExtension.getDynamoDbClient(), - dynamoDbExtension.getDynamoDbAsyncClient(), - PROFILES_TABLE_NAME); - } - - @Test - void testSetGet() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", "emoji", - "the very model of a modern major general", - null, "acommitment".getBytes()); - profiles.set(uuid, profile); - - Optional retrieved = profiles.get(uuid, "123"); - - assertThat(retrieved.isPresent()).isTrue(); - assertThat(retrieved.get().getName()).isEqualTo(profile.getName()); - assertThat(retrieved.get().getAvatar()).isEqualTo(profile.getAvatar()); - assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment()); - assertThat(retrieved.get().getAbout()).isEqualTo(profile.getAbout()); - assertThat(retrieved.get().getAboutEmoji()).isEqualTo(profile.getAboutEmoji()); - } - - @Test - void testDeleteReset() { - UUID uuid = UUID.randomUUID(); - profiles.set(uuid, new VersionedProfile("123", "foo", "avatarLocation", "emoji", - "the very model of a modern major general", - null, "acommitment".getBytes())); - - profiles.deleteAll(uuid); - - VersionedProfile updatedProfile = new VersionedProfile("123", "name", "differentAvatarLocation", - "differentEmoji", "changed text", "paymentAddress", "differentcommitment".getBytes(StandardCharsets.UTF_8)); - - profiles.set(uuid, updatedProfile); - - Optional retrieved = profiles.get(uuid, "123"); - - assertThat(retrieved.isPresent()).isTrue(); - assertThat(retrieved.get().getName()).isEqualTo(updatedProfile.getName()); - assertThat(retrieved.get().getAvatar()).isEqualTo(updatedProfile.getAvatar()); - assertThat(retrieved.get().getCommitment()).isEqualTo(updatedProfile.getCommitment()); - assertThat(retrieved.get().getAbout()).isEqualTo(updatedProfile.getAbout()); - assertThat(retrieved.get().getAboutEmoji()).isEqualTo(updatedProfile.getAboutEmoji()); - } - - @Test - void testSetGetNullOptionalFields() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profile = new VersionedProfile("123", "foo", null, null, null, null, - "acommitment".getBytes()); - profiles.set(uuid, profile); - - Optional retrieved = profiles.get(uuid, "123"); - - assertThat(retrieved.isPresent()).isTrue(); - assertThat(retrieved.get().getName()).isEqualTo(profile.getName()); - assertThat(retrieved.get().getAvatar()).isEqualTo(profile.getAvatar()); - assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment()); - assertThat(retrieved.get().getAbout()).isEqualTo(profile.getAbout()); - assertThat(retrieved.get().getAboutEmoji()).isEqualTo(profile.getAboutEmoji()); - } - - @Test - void testSetReplace() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", null, null, - "paymentAddress", "acommitment".getBytes()); - profiles.set(uuid, profile); - - Optional retrieved = profiles.get(uuid, "123"); - - assertThat(retrieved.isPresent()).isTrue(); - assertThat(retrieved.get().getName()).isEqualTo(profile.getName()); - assertThat(retrieved.get().getAvatar()).isEqualTo(profile.getAvatar()); - assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment()); - assertThat(retrieved.get().getAbout()).isNull(); - assertThat(retrieved.get().getAboutEmoji()).isNull(); - - VersionedProfile updated = new VersionedProfile("123", "bar", "baz", "emoji", "bio", null, - "boof".getBytes()); - profiles.set(uuid, updated); - - retrieved = profiles.get(uuid, "123"); - - assertThat(retrieved.isPresent()).isTrue(); - assertThat(retrieved.get().getName()).isEqualTo(updated.getName()); - assertThat(retrieved.get().getAbout()).isEqualTo(updated.getAbout()); - assertThat(retrieved.get().getAboutEmoji()).isEqualTo(updated.getAboutEmoji()); - assertThat(retrieved.get().getAvatar()).isEqualTo(updated.getAvatar()); - - // Commitment should be unchanged after an overwrite - assertThat(retrieved.get().getCommitment()).isEqualTo(profile.getCommitment()); - } - - @Test - void testMultipleVersions() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profileOne = new VersionedProfile("123", "foo", "avatarLocation", null, null, - null, "acommitmnet".getBytes()); - VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", "emoji", - "i keep typing emoju for some reason", - null, "boof".getBytes()); - - profiles.set(uuid, profileOne); - profiles.set(uuid, profileTwo); - - Optional retrieved = profiles.get(uuid, "123"); - - assertThat(retrieved.isPresent()).isTrue(); - assertThat(retrieved.get().getName()).isEqualTo(profileOne.getName()); - assertThat(retrieved.get().getAvatar()).isEqualTo(profileOne.getAvatar()); - assertThat(retrieved.get().getCommitment()).isEqualTo(profileOne.getCommitment()); - assertThat(retrieved.get().getAbout()).isEqualTo(profileOne.getAbout()); - assertThat(retrieved.get().getAboutEmoji()).isEqualTo(profileOne.getAboutEmoji()); - - retrieved = profiles.get(uuid, "345"); - - assertThat(retrieved.isPresent()).isTrue(); - assertThat(retrieved.get().getName()).isEqualTo(profileTwo.getName()); - assertThat(retrieved.get().getAvatar()).isEqualTo(profileTwo.getAvatar()); - assertThat(retrieved.get().getCommitment()).isEqualTo(profileTwo.getCommitment()); - assertThat(retrieved.get().getAbout()).isEqualTo(profileTwo.getAbout()); - assertThat(retrieved.get().getAboutEmoji()).isEqualTo(profileTwo.getAboutEmoji()); - } - - @Test - void testMissing() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profile = new VersionedProfile("123", "foo", "avatarLocation", null, null, - null, "aDigest".getBytes()); - profiles.set(uuid, profile); - - Optional retrieved = profiles.get(uuid, "888"); - assertThat(retrieved.isPresent()).isFalse(); - } - - - @Test - void testDelete() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profileOne = new VersionedProfile("123", "foo", "avatarLocation", null, null, - null, "aDigest".getBytes()); - VersionedProfile profileTwo = new VersionedProfile("345", "bar", "baz", null, null, null, "boof".getBytes()); - - profiles.set(uuid, profileOne); - profiles.set(uuid, profileTwo); - - profiles.deleteAll(uuid); - - Optional retrieved = profiles.get(uuid, "123"); - - assertThat(retrieved.isPresent()).isFalse(); - - retrieved = profiles.get(uuid, "345"); - - assertThat(retrieved.isPresent()).isFalse(); - } - - @ParameterizedTest - @MethodSource - void buildUpdateExpression(final VersionedProfile profile, final String expectedUpdateExpression) { - assertEquals(expectedUpdateExpression, Profiles.buildUpdateExpression(profile)); - } - - private static Stream buildUpdateExpression() { - final byte[] commitment = "commitment".getBytes(StandardCharsets.UTF_8); - - return Stream.of( - Arguments.of( - new VersionedProfile("version", "name", "avatar", "emoji", "about", "paymentAddress", commitment), - "SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #about = :about, #aboutEmoji = :aboutEmoji, #paymentAddress = :paymentAddress"), - - Arguments.of( - new VersionedProfile("version", "name", "avatar", "emoji", "about", null, commitment), - "SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #about = :about, #aboutEmoji = :aboutEmoji REMOVE #paymentAddress"), - - Arguments.of( - new VersionedProfile("version", "name", "avatar", "emoji", null, null, commitment), - "SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #aboutEmoji = :aboutEmoji REMOVE #about, #paymentAddress"), - - Arguments.of( - new VersionedProfile("version", "name", "avatar", null, null, null, commitment), - "SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar REMOVE #about, #aboutEmoji, #paymentAddress"), - - Arguments.of( - new VersionedProfile("version", "name", null, null, null, null, commitment), - "SET #commitment = if_not_exists(#commitment, :commitment), #name = :name REMOVE #avatar, #about, #aboutEmoji, #paymentAddress"), - - Arguments.of( - new VersionedProfile("version", null, null, null, null, null, commitment), - "SET #commitment = if_not_exists(#commitment, :commitment) REMOVE #name, #avatar, #about, #aboutEmoji, #paymentAddress") - ); - } - - @ParameterizedTest - @MethodSource - void buildUpdateExpressionAttributeValues(final VersionedProfile profile, final Map expectedAttributeValues) { - assertEquals(expectedAttributeValues, Profiles.buildUpdateExpressionAttributeValues(profile)); - } - - private static Stream buildUpdateExpressionAttributeValues() { - final byte[] commitment = "commitment".getBytes(StandardCharsets.UTF_8); - - return Stream.of( - Arguments.of( - new VersionedProfile("version", "name", "avatar", "emoji", "about", "paymentAddress", commitment), - Map.of( - ":commitment", AttributeValues.fromByteArray(commitment), - ":name", AttributeValues.fromString("name"), - ":avatar", AttributeValues.fromString("avatar"), - ":aboutEmoji", AttributeValues.fromString("emoji"), - ":about", AttributeValues.fromString("about"), - ":paymentAddress", AttributeValues.fromString("paymentAddress"))), - - Arguments.of( - new VersionedProfile("version", "name", "avatar", "emoji", "about", null, commitment), - Map.of( - ":commitment", AttributeValues.fromByteArray(commitment), - ":name", AttributeValues.fromString("name"), - ":avatar", AttributeValues.fromString("avatar"), - ":aboutEmoji", AttributeValues.fromString("emoji"), - ":about", AttributeValues.fromString("about"))), - - Arguments.of( - new VersionedProfile("version", "name", "avatar", "emoji", null, null, commitment), - Map.of( - ":commitment", AttributeValues.fromByteArray(commitment), - ":name", AttributeValues.fromString("name"), - ":avatar", AttributeValues.fromString("avatar"), - ":aboutEmoji", AttributeValues.fromString("emoji"))), - - Arguments.of( - new VersionedProfile("version", "name", "avatar", null, null, null, commitment), - Map.of( - ":commitment", AttributeValues.fromByteArray(commitment), - ":name", AttributeValues.fromString("name"), - ":avatar", AttributeValues.fromString("avatar"))), - - Arguments.of( - new VersionedProfile("version", "name", null, null, null, null, commitment), - Map.of( - ":commitment", AttributeValues.fromByteArray(commitment), - ":name", AttributeValues.fromString("name"))), - - Arguments.of( - new VersionedProfile("version", null, null, null, null, null, commitment), - Map.of(":commitment", AttributeValues.fromByteArray(commitment))) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDbTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDbTest.java deleted file mode 100644 index 5da07d2c2..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDbTest.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.util.Random; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class PushChallengeDynamoDbTest { - - private PushChallengeDynamoDb pushChallengeDynamoDb; - - private static final long CURRENT_TIME_MILLIS = 1_000_000_000; - - private static final Random RANDOM = new Random(); - private static final String TABLE_NAME = "push_challenge_test"; - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(TABLE_NAME) - .hashKey(PushChallengeDynamoDb.KEY_ACCOUNT_UUID) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(PushChallengeDynamoDb.KEY_ACCOUNT_UUID) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - @BeforeEach - void setUp() { - this.pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbExtension.getDynamoDbClient(), TABLE_NAME, Clock.fixed( - Instant.ofEpochMilli(CURRENT_TIME_MILLIS), ZoneId.systemDefault())); - } - - @Test - void add() { - final UUID uuid = UUID.randomUUID(); - - assertTrue(pushChallengeDynamoDb.add(uuid, generateRandomToken(), Duration.ofMinutes(1))); - assertFalse(pushChallengeDynamoDb.add(uuid, generateRandomToken(), Duration.ofMinutes(1))); - } - - @Test - void remove() { - final UUID uuid = UUID.randomUUID(); - final byte[] token = generateRandomToken(); - - assertFalse(pushChallengeDynamoDb.remove(uuid, token)); - assertTrue(pushChallengeDynamoDb.add(uuid, token, Duration.ofMinutes(1))); - assertTrue(pushChallengeDynamoDb.remove(uuid, token)); - assertTrue(pushChallengeDynamoDb.add(uuid, token, Duration.ofMinutes(-1))); - assertFalse(pushChallengeDynamoDb.remove(uuid, token)); - } - - @Test - void getExpirationTimestamp() { - assertEquals((CURRENT_TIME_MILLIS / 1000) + 3600, - pushChallengeDynamoDb.getExpirationTimestamp(Duration.ofHours(1))); - } - - private static byte[] generateRandomToken() { - final byte[] token = new byte[16]; - RANDOM.nextBytes(token); - - return token; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceSupplierTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceSupplierTest.java deleted file mode 100644 index dacbf13c3..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountAndDeviceSupplierTest.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.util.Pair; - -class RefreshingAccountAndDeviceSupplierTest { - - @Test - void test() { - - final AccountsManager accountsManager = mock(AccountsManager.class); - - final UUID uuid = UUID.randomUUID(); - final long deviceId = 2L; - - final Account initialAccount = mock(Account.class); - final Device initialDevice = mock(Device.class); - - when(initialAccount.getUuid()).thenReturn(uuid); - when(initialDevice.getId()).thenReturn(deviceId); - when(initialAccount.getDevice(deviceId)).thenReturn(Optional.of(initialDevice)); - - when(accountsManager.getByAccountIdentifier(any(UUID.class))).thenAnswer(answer -> { - final Account account = mock(Account.class); - final Device device = mock(Device.class); - - when(account.getUuid()).thenReturn(answer.getArgument(0, UUID.class)); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - when(device.getId()).thenReturn(deviceId); - - return Optional.of(account); - }); - - final RefreshingAccountAndDeviceSupplier refreshingAccountAndDeviceSupplier = new RefreshingAccountAndDeviceSupplier( - initialAccount, deviceId, accountsManager); - - Pair accountAndDevice = refreshingAccountAndDeviceSupplier.get(); - - assertSame(initialAccount, accountAndDevice.first()); - assertSame(initialDevice, accountAndDevice.second()); - - accountAndDevice = refreshingAccountAndDeviceSupplier.get(); - - assertSame(initialAccount, accountAndDevice.first()); - assertSame(initialDevice, accountAndDevice.second()); - - when(initialAccount.isStale()).thenReturn(true); - - accountAndDevice = refreshingAccountAndDeviceSupplier.get(); - - assertNotSame(initialAccount, accountAndDevice.first()); - assertNotSame(initialDevice, accountAndDevice.second()); - - assertEquals(uuid, accountAndDevice.first().getUuid()); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryTest.java deleted file mode 100644 index 888716dbc..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryTest.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Duration; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.util.AttributeValues; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.MutableClock; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -public class RegistrationRecoveryTest { - - private static final String TABLE_NAME = "registration_recovery_passwords"; - - private static final MutableClock CLOCK = MockUtils.mutableClock(0); - - private static final Duration EXPIRATION = Duration.ofSeconds(1000); - - private static final String NUMBER = "+18005555555"; - - private static final SaltedTokenHash ORIGINAL_HASH = SaltedTokenHash.generateFor("pass1"); - - private static final SaltedTokenHash ANOTHER_HASH = SaltedTokenHash.generateFor("pass2"); - - @RegisterExtension - private static final DynamoDbExtension DB_EXTENSION = DynamoDbExtension.builder() - .tableName(TABLE_NAME) - .hashKey(RegistrationRecoveryPasswords.KEY_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(RegistrationRecoveryPasswords.KEY_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .build(); - - private RegistrationRecoveryPasswords store; - - private RegistrationRecoveryPasswordsManager manager; - - @BeforeEach - public void before() throws Exception { - CLOCK.setTimeMillis(Clock.systemUTC().millis()); - store = new RegistrationRecoveryPasswords( - DB_EXTENSION.getTableName(), - EXPIRATION, - DB_EXTENSION.getDynamoDbClient(), - DB_EXTENSION.getDynamoDbAsyncClient(), - CLOCK - ); - manager = new RegistrationRecoveryPasswordsManager(store); - } - - @Test - public void testLookupAfterWrite() throws Exception { - store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); - final long initialExp = fetchTimestamp(NUMBER); - final long expectedExpiration = CLOCK.instant().getEpochSecond() + EXPIRATION.getSeconds(); - assertEquals(expectedExpiration, initialExp); - - final Optional saltedTokenHash = store.lookup(NUMBER).get(); - assertTrue(saltedTokenHash.isPresent()); - assertEquals(ORIGINAL_HASH.salt(), saltedTokenHash.get().salt()); - assertEquals(ORIGINAL_HASH.hash(), saltedTokenHash.get().hash()); - } - - @Test - public void testLookupAfterRefresh() throws Exception { - store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); - - CLOCK.increment(50, TimeUnit.SECONDS); - store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); - final long updatedExp = fetchTimestamp(NUMBER); - final long expectedExp = CLOCK.instant().getEpochSecond() + EXPIRATION.getSeconds(); - assertEquals(expectedExp, updatedExp); - - final Optional saltedTokenHash = store.lookup(NUMBER).get(); - assertTrue(saltedTokenHash.isPresent()); - assertEquals(ORIGINAL_HASH.salt(), saltedTokenHash.get().salt()); - assertEquals(ORIGINAL_HASH.hash(), saltedTokenHash.get().hash()); - } - - @Test - public void testReplace() throws Exception { - store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); - store.addOrReplace(NUMBER, ANOTHER_HASH).get(); - - final Optional saltedTokenHash = store.lookup(NUMBER).get(); - assertTrue(saltedTokenHash.isPresent()); - assertEquals(ANOTHER_HASH.salt(), saltedTokenHash.get().salt()); - assertEquals(ANOTHER_HASH.hash(), saltedTokenHash.get().hash()); - } - - @Test - public void testRemove() throws Exception { - store.addOrReplace(NUMBER, ORIGINAL_HASH).get(); - assertTrue(store.lookup(NUMBER).get().isPresent()); - - store.removeEntry(NUMBER).get(); - assertTrue(store.lookup(NUMBER).get().isEmpty()); - } - - @Test - public void testManagerFlow() throws Exception { - final byte[] password = "password".getBytes(StandardCharsets.UTF_8); - final byte[] updatedPassword = "udpate".getBytes(StandardCharsets.UTF_8); - final byte[] wrongPassword = "qwerty123".getBytes(StandardCharsets.UTF_8); - - // initial store - manager.storeForCurrentNumber(NUMBER, password).get(); - assertTrue(manager.verify(NUMBER, password).get()); - assertFalse(manager.verify(NUMBER, wrongPassword).get()); - - // update - manager.storeForCurrentNumber(NUMBER, password).get(); - assertTrue(manager.verify(NUMBER, password).get()); - assertFalse(manager.verify(NUMBER, wrongPassword).get()); - - // replace - manager.storeForCurrentNumber(NUMBER, updatedPassword).get(); - assertTrue(manager.verify(NUMBER, updatedPassword).get()); - assertFalse(manager.verify(NUMBER, password).get()); - assertFalse(manager.verify(NUMBER, wrongPassword).get()); - - manager.removeForNumber(NUMBER).get(); - assertFalse(manager.verify(NUMBER, updatedPassword).get()); - assertFalse(manager.verify(NUMBER, password).get()); - assertFalse(manager.verify(NUMBER, wrongPassword).get()); - } - - private static long fetchTimestamp(final String number) throws ExecutionException, InterruptedException { - return DB_EXTENSION.getDynamoDbAsyncClient().getItem(GetItemRequest.builder() - .tableName(DB_EXTENSION.getTableName()) - .key(Map.of(RegistrationRecoveryPasswords.KEY_E164, AttributeValues.fromString(number))) - .build()) - .thenApply(getItemResponse -> { - final Map item = getItemResponse.item(); - if (item == null || !item.containsKey(RegistrationRecoveryPasswords.ATTR_EXP)) { - throw new RuntimeException("Data not found"); - } - final String exp = item.get(RegistrationRecoveryPasswords.ATTR_EXP).n(); - return Long.parseLong(exp); - }) - .get(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManagerTest.java deleted file mode 100644 index 1cb849d8f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManagerTest.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; - -class RemoteConfigsManagerTest { - - private RemoteConfigs remoteConfigs; - private RemoteConfigsManager remoteConfigsManager; - - @BeforeEach - void setup() { - this.remoteConfigs = mock(RemoteConfigs.class); - this.remoteConfigsManager = new RemoteConfigsManager(remoteConfigs); - } - - @Test - void testGetAll() { - remoteConfigsManager.getAll(); - remoteConfigsManager.getAll(); - - // A memoized supplier should prevent multiple calls to the underlying data source - verify(remoteConfigs, times(1)).getAll(); - } - - @Test - void testSet() { - final RemoteConfig remoteConfig = mock(RemoteConfig.class); - - remoteConfigsManager.set(remoteConfig); - remoteConfigsManager.set(remoteConfig); - - verify(remoteConfigs, times(2)).set(remoteConfig); - } - - @Test - void testDelete() { - final String name = "name"; - - remoteConfigsManager.delete(name); - remoteConfigsManager.delete(name); - - verify(remoteConfigs, times(2)).delete(name); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsTest.java deleted file mode 100644 index 9fcebb562..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsTest.java +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; -import java.util.List; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; - -class RemoteConfigsTest { - - private static final String REMOTE_CONFIGS_TABLE_NAME = "remote_configs_test"; - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(REMOTE_CONFIGS_TABLE_NAME) - .hashKey(RemoteConfigs.KEY_NAME) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(RemoteConfigs.KEY_NAME) - .attributeType(ScalarAttributeType.S) - .build()) - .build(); - - private RemoteConfigs remoteConfigs; - - @BeforeEach - void setUp() { - remoteConfigs = new RemoteConfigs(dynamoDbExtension.getDynamoDbClient(), REMOTE_CONFIGS_TABLE_NAME); - } - - @Test - void testStore() { - remoteConfigs.set(new RemoteConfig("android.stickers", 50, Set.of(AuthHelper.VALID_UUID, AuthHelper.VALID_UUID_TWO), "FALSE", "TRUE", null)); - remoteConfigs.set(new RemoteConfig("value.sometimes", 25, Set.of(AuthHelper.VALID_UUID_TWO), "default", "custom", null)); - - List configs = remoteConfigs.getAll(); - - assertThat(configs).hasSize(2); - - assertThat(configs.get(0).getName()).isEqualTo("android.stickers"); - assertThat(configs.get(0).getValue()).isEqualTo("TRUE"); - assertThat(configs.get(0).getDefaultValue()).isEqualTo("FALSE"); - assertThat(configs.get(0).getPercentage()).isEqualTo(50); - assertThat(configs.get(0).getUuids()).hasSize(2); - assertThat(configs.get(0).getUuids()).contains(AuthHelper.VALID_UUID); - assertThat(configs.get(0).getUuids()).contains(AuthHelper.VALID_UUID_TWO); - assertThat(configs.get(0).getUuids()).doesNotContain(AuthHelper.INVALID_UUID); - - assertThat(configs.get(1).getName()).isEqualTo("value.sometimes"); - assertThat(configs.get(1).getValue()).isEqualTo("custom"); - assertThat(configs.get(1).getDefaultValue()).isEqualTo("default"); - assertThat(configs.get(1).getPercentage()).isEqualTo(25); - assertThat(configs.get(1).getUuids()).hasSize(1); - assertThat(configs.get(1).getUuids()).contains(AuthHelper.VALID_UUID_TWO); - assertThat(configs.get(1).getUuids()).doesNotContain(AuthHelper.VALID_UUID); - assertThat(configs.get(1).getUuids()).doesNotContain(AuthHelper.INVALID_UUID); - } - - @Test - void testUpdate() { - remoteConfigs.set(new RemoteConfig("android.stickers", 50, Set.of(), "FALSE", "TRUE", null)); - remoteConfigs.set(new RemoteConfig("value.sometimes", 22, Set.of(), "def", "!", null)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 50, Set.of(AuthHelper.DISABLED_UUID), "FALSE", "TRUE", null)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 75, Set.of(), "FALSE", "TRUE", null)); - remoteConfigs.set(new RemoteConfig("value.sometimes", 77, Set.of(), "hey", "wut", null)); - - List configs = remoteConfigs.getAll(); - - assertThat(configs).hasSize(3); - - assertThat(configs.get(0).getName()).isEqualTo("android.stickers"); - assertThat(configs.get(0).getPercentage()).isEqualTo(50); - assertThat(configs.get(0).getUuids()).isEmpty(); - assertThat(configs.get(0).getDefaultValue()).isEqualTo("FALSE"); - assertThat(configs.get(0).getValue()).isEqualTo("TRUE"); - - assertThat(configs.get(1).getName()).isEqualTo("ios.stickers"); - assertThat(configs.get(1).getPercentage()).isEqualTo(75); - assertThat(configs.get(1).getUuids()).isEmpty(); - assertThat(configs.get(1).getDefaultValue()).isEqualTo("FALSE"); - assertThat(configs.get(1).getValue()).isEqualTo("TRUE"); - - assertThat(configs.get(2).getName()).isEqualTo("value.sometimes"); - assertThat(configs.get(2).getPercentage()).isEqualTo(77); - assertThat(configs.get(2).getUuids()).isEmpty(); - assertThat(configs.get(2).getDefaultValue()).isEqualTo("hey"); - assertThat(configs.get(2).getValue()).isEqualTo("wut"); - } - - @Test - void testDelete() { - remoteConfigs.set(new RemoteConfig("android.stickers", 50, Set.of(AuthHelper.VALID_UUID), "FALSE", "TRUE", null)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 50, Set.of(), "FALSE", "TRUE", null)); - remoteConfigs.set(new RemoteConfig("ios.stickers", 75, Set.of(), "FALSE", "TRUE", null)); - remoteConfigs.set(new RemoteConfig("value.always", 100, Set.of(), "never", "always", null)); - remoteConfigs.delete("android.stickers"); - - List configs = remoteConfigs.getAll(); - - assertThat(configs).hasSize(2); - - assertThat(configs.get(0).getName()).isEqualTo("ios.stickers"); - assertThat(configs.get(0).getPercentage()).isEqualTo(75); - assertThat(configs.get(0).getDefaultValue()).isEqualTo("FALSE"); - assertThat(configs.get(0).getValue()).isEqualTo("TRUE"); - - assertThat(configs.get(1).getName()).isEqualTo("value.always"); - assertThat(configs.get(1).getPercentage()).isEqualTo(100); - assertThat(configs.get(1).getValue()).isEqualTo("always"); - assertThat(configs.get(1).getDefaultValue()).isEqualTo("never"); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDbTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDbTest.java deleted file mode 100644 index 5281ea240..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDbTest.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.time.Duration; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.util.UUIDUtil; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class ReportMessageDynamoDbTest { - - private ReportMessageDynamoDb reportMessageDynamoDb; - - private static final String TABLE_NAME = "report_message_test"; - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(TABLE_NAME) - .hashKey(ReportMessageDynamoDb.KEY_HASH) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(ReportMessageDynamoDb.KEY_HASH) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - - @BeforeEach - void setUp() { - this.reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbExtension.getDynamoDbClient(), TABLE_NAME, Duration.ofDays(1)); - } - - @Test - void testStore() { - - final byte[] hash1 = UUIDUtil.toBytes(UUID.randomUUID()); - final byte[] hash2 = UUIDUtil.toBytes(UUID.randomUUID()); - - assertAll("database should be empty", - () -> assertFalse(reportMessageDynamoDb.remove(hash1)), - () -> assertFalse(reportMessageDynamoDb.remove(hash2)) - ); - - reportMessageDynamoDb.store(hash1); - reportMessageDynamoDb.store(hash2); - - assertAll("both hashes should be found", - () -> assertTrue(reportMessageDynamoDb.remove(hash1)), - () -> assertTrue(reportMessageDynamoDb.remove(hash2)) - ); - - assertAll( "database should be empty", - () -> assertFalse(reportMessageDynamoDb.remove(hash1)), - () -> assertFalse(reportMessageDynamoDb.remove(hash2)) - ); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java deleted file mode 100644 index 2cc6d9adb..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2021-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.time.Duration; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; - -class ReportMessageManagerTest { - - private ReportMessageDynamoDb reportMessageDynamoDb; - - private ReportMessageManager reportMessageManager; - - private String sourceNumber; - private UUID sourceAci; - private UUID sourcePni; - private Account sourceAccount; - private UUID messageGuid; - private UUID reporterUuid; - - @RegisterExtension - static RedisClusterExtension RATE_LIMIT_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - @BeforeEach - void setUp() { - reportMessageDynamoDb = mock(ReportMessageDynamoDb.class); - - reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, - RATE_LIMIT_CLUSTER_EXTENSION.getRedisCluster(), Duration.ofDays(1)); - - sourceNumber = "+15105551111"; - sourceAci = UUID.randomUUID(); - sourcePni = UUID.randomUUID(); - messageGuid = UUID.randomUUID(); - reporterUuid = UUID.randomUUID(); - - sourceAccount = mock(Account.class); - when(sourceAccount.getUuid()).thenReturn(sourceAci); - when(sourceAccount.getNumber()).thenReturn(sourceNumber); - when(sourceAccount.getPhoneNumberIdentifier()).thenReturn(sourcePni); - } - - @Test - void testStore() { - assertDoesNotThrow(() -> reportMessageManager.store(null, messageGuid)); - - verifyNoInteractions(reportMessageDynamoDb); - - reportMessageManager.store(sourceAci.toString(), messageGuid); - - verify(reportMessageDynamoDb).store(any()); - - doThrow(RuntimeException.class) - .when(reportMessageDynamoDb).store(any()); - - assertDoesNotThrow(() -> reportMessageManager.store(sourceAci.toString(), messageGuid)); - } - - @Test - void testReport() { - final ReportedMessageListener listener = mock(ReportedMessageListener.class); - reportMessageManager.addListener(listener); - - when(reportMessageDynamoDb.remove(any())).thenReturn(false); - reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), messageGuid, - reporterUuid, Optional.empty(), "user-agent"); - - assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); - - when(reportMessageDynamoDb.remove(any())).thenReturn(true); - reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), messageGuid, - reporterUuid, Optional.empty(), "user-agent"); - - assertEquals(1, reportMessageManager.getRecentReportCount(sourceAccount)); - verify(listener).handleMessageReported(sourceNumber, messageGuid, reporterUuid, Optional.empty()); - } - - @Test - void testReportMultipleReporters() { - when(reportMessageDynamoDb.remove(any())).thenReturn(true); - assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); - - for (int i = 0; i < 100; i++) { - reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), - messageGuid, UUID.randomUUID(), Optional.empty(), "user-agent"); - } - - assertTrue(reportMessageManager.getRecentReportCount(sourceAccount) > 10); - } - - @Test - void testReportSingleReporter() { - when(reportMessageDynamoDb.remove(any())).thenReturn(true); - assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); - - for (int i = 0; i < 100; i++) { - reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), - messageGuid, - reporterUuid, Optional.empty(), "user-agent"); - } - - assertEquals(1, reportMessageManager.getRecentReportCount(sourceAccount)); - } - - @Test - void testReportMultipleReportersByPni() { - when(reportMessageDynamoDb.remove(any())).thenReturn(true); - assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); - - for (int i = 0; i < 100; i++) { - reportMessageManager.report(Optional.empty(), Optional.of(sourceAci), Optional.of(sourcePni), - messageGuid, UUID.randomUUID(), Optional.empty(), "user-agent"); - } - - reportMessageManager.report(Optional.empty(), Optional.of(sourceAci), Optional.empty(), - messageGuid, UUID.randomUUID(), Optional.empty(), "user-agent"); - - final int recentReportCount = reportMessageManager.getRecentReportCount(sourceAccount); - assertTrue(recentReportCount > 10); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/StoredVerificationCodeManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/StoredVerificationCodeManagerTest.java deleted file mode 100644 index 9636d9783..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/StoredVerificationCodeManagerTest.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; - -class StoredVerificationCodeManagerTest { - - private VerificationCodeStore verificationCodeStore; - - private StoredVerificationCodeManager storedVerificationCodeManager; - - @BeforeEach - void setUp() { - verificationCodeStore = mock(VerificationCodeStore.class); - - storedVerificationCodeManager = new StoredVerificationCodeManager(verificationCodeStore); - } - - @Test - void store() { - final String number = "+18005551234"; - final StoredVerificationCode code = mock(StoredVerificationCode.class); - - storedVerificationCodeManager.store(number, code); - - verify(verificationCodeStore).insert(number, code); - } - - @Test - void remove() { - final String number = "+18005551234"; - - storedVerificationCodeManager.remove(number); - - verify(verificationCodeStore).remove(number); - } - - @Test - void getCodeForNumber() { - final String number = "+18005551234"; - - when(verificationCodeStore.findForNumber(number)).thenReturn(Optional.empty()); - assertEquals(Optional.empty(), storedVerificationCodeManager.getCodeForNumber(number)); - - final StoredVerificationCode storedVerificationCode = mock(StoredVerificationCode.class); - - when(verificationCodeStore.findForNumber(number)).thenReturn(Optional.of(storedVerificationCode)); - assertEquals(Optional.of(storedVerificationCode), storedVerificationCodeManager.getCodeForNumber(number)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionManagerTest.java deleted file mode 100644 index 0a12db09d..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionManagerTest.java +++ /dev/null @@ -1,301 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult.Type.FOUND; -import static org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult.Type.NOT_STORED; -import static org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult.Type.PASSWORD_MISMATCH; - -import java.security.SecureRandom; -import java.time.Duration; -import java.time.Instant; -import java.util.Base64; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.function.Consumer; -import javax.annotation.Nonnull; -import javax.ws.rs.ClientErrorException; -import org.assertj.core.api.Condition; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.storage.SubscriptionManager.GetResult; -import org.whispersystems.textsecuregcm.storage.SubscriptionManager.Record; -import org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer; -import org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessor; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.Projection; -import software.amazon.awssdk.services.dynamodb.model.ProjectionType; -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class SubscriptionManagerTest { - - private static final long NOW_EPOCH_SECONDS = 1_500_000_000L; - private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3); - private static final String SUBSCRIPTIONS_TABLE_NAME = "subscriptions"; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder(). - tableName(SUBSCRIPTIONS_TABLE_NAME). - hashKey(SubscriptionManager.KEY_USER). - attributeDefinition(AttributeDefinition.builder(). - attributeName(SubscriptionManager.KEY_USER). - attributeType(ScalarAttributeType.B). - build()). - attributeDefinition(AttributeDefinition.builder(). - attributeName(SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID). - attributeType(ScalarAttributeType.B). - build()). - globalSecondaryIndex(GlobalSecondaryIndex.builder(). - indexName(SubscriptionManager.INDEX_NAME). - keySchema(KeySchemaElement.builder(). - attributeName(SubscriptionManager.KEY_PROCESSOR_ID_CUSTOMER_ID). - keyType(KeyType.HASH). - build()). - projection(Projection.builder(). - projectionType(ProjectionType.KEYS_ONLY). - build()). - provisionedThroughput(ProvisionedThroughput.builder(). - readCapacityUnits(20L). - writeCapacityUnits(20L). - build()). - build()). - build(); - - byte[] user; - byte[] password; - String customer; - Instant created; - SubscriptionManager subscriptionManager; - - @BeforeEach - void beforeEach() { - user = getRandomBytes(16); - password = getRandomBytes(16); - customer = Base64.getEncoder().encodeToString(getRandomBytes(16)); - created = Instant.ofEpochSecond(NOW_EPOCH_SECONDS); - subscriptionManager = new SubscriptionManager( - SUBSCRIPTIONS_TABLE_NAME, dynamoDbExtension.getDynamoDbAsyncClient()); - } - - @Test - void testCreateOnlyOnce() { - byte[] password1 = getRandomBytes(16); - byte[] password2 = getRandomBytes(16); - Instant created1 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS); - Instant created2 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); - - CompletableFuture getFuture = subscriptionManager.get(user, password1); - assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { - assertThat(getResult.type).isEqualTo(NOT_STORED); - assertThat(getResult.record).isNull(); - }); - - getFuture = subscriptionManager.get(user, password2); - assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { - assertThat(getResult.type).isEqualTo(NOT_STORED); - assertThat(getResult.record).isNull(); - }); - - CompletableFuture createFuture = - subscriptionManager.create(user, password1, created1); - Consumer recordRequirements = checkFreshlyCreatedRecord(user, password1, created1); - assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements); - - // password check fails so this should return null - createFuture = subscriptionManager.create(user, password2, created2); - assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).isNull(); - - // password check matches, but the record already exists so nothing should get updated - createFuture = subscriptionManager.create(user, password1, created2); - assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements); - } - - @Test - void testGet() { - byte[] wrongUser = getRandomBytes(16); - byte[] wrongPassword = getRandomBytes(16); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); - - assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { - assertThat(getResult.type).isEqualTo(FOUND); - assertThat(getResult.record).isNotNull().satisfies(checkFreshlyCreatedRecord(user, password, created)); - }); - - assertThat(subscriptionManager.get(user, wrongPassword)).succeedsWithin(DEFAULT_TIMEOUT) - .satisfies(getResult -> { - assertThat(getResult.type).isEqualTo(PASSWORD_MISMATCH); - assertThat(getResult.record).isNull(); - }); - - assertThat(subscriptionManager.get(wrongUser, password)).succeedsWithin(DEFAULT_TIMEOUT) - .satisfies(getResult -> { - assertThat(getResult.type).isEqualTo(NOT_STORED); - assertThat(getResult.record).isNull(); - }); - } - - @Test - void testSetCustomerIdAndProcessor() throws Exception { - Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); - - final CompletableFuture getUser = subscriptionManager.get(user, password); - assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT); - final Record userRecord = getUser.get().record; - - assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord, - new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE), - subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT) - .hasFieldOrPropertyWithValue("processorCustomer", - Optional.of(new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))); - - final Condition clientError409Condition = new Condition<>(e -> - e instanceof ClientErrorException cee && cee.getResponse().getStatus() == 409, "Client error: 409"); - - // changing the customer ID is not permitted - assertThat( - subscriptionManager.setProcessorAndCustomerId(userRecord, - new ProcessorCustomer(customer + "1", SubscriptionProcessor.STRIPE), - subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT) - .withThrowableOfType(ExecutionException.class) - .withCauseInstanceOf(ClientErrorException.class) - .extracting(Throwable::getCause) - .satisfies(clientError409Condition); - - // calling setProcessorAndCustomerId() with the same customer ID is also an error - assertThat( - subscriptionManager.setProcessorAndCustomerId(userRecord, - new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE), - subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT) - .withThrowableOfType(ExecutionException.class) - .withCauseInstanceOf(ClientErrorException.class) - .extracting(Throwable::getCause) - .satisfies(clientError409Condition); - - assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer( - new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))) - .succeedsWithin(DEFAULT_TIMEOUT). - isEqualTo(user); - } - - @Test - void testLookupByCustomerId() throws Exception { - Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); - - final CompletableFuture getUser = subscriptionManager.get(user, password); - assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT); - final Record userRecord = getUser.get().record; - - assertThat(subscriptionManager.setProcessorAndCustomerId(userRecord, - new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE), - subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT); - assertThat(subscriptionManager.getSubscriberUserByProcessorCustomer( - new ProcessorCustomer(customer, SubscriptionProcessor.STRIPE))). - succeedsWithin(DEFAULT_TIMEOUT). - isEqualTo(user); - } - - @Test - void testCanceledAt() { - Instant canceled = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 42); - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); - assertThat(subscriptionManager.canceledAt(user, canceled)).succeedsWithin(DEFAULT_TIMEOUT); - assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { - assertThat(getResult).isNotNull(); - assertThat(getResult.type).isEqualTo(FOUND); - assertThat(getResult.record).isNotNull().satisfies(record -> { - assertThat(record.accessedAt).isEqualTo(canceled); - assertThat(record.canceledAt).isEqualTo(canceled); - assertThat(record.subscriptionId).isNull(); - }); - }); - } - - @Test - void testSubscriptionCreated() { - String subscriptionId = Base64.getEncoder().encodeToString(getRandomBytes(16)); - Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1); - long level = 42; - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); - assertThat(subscriptionManager.subscriptionCreated(user, subscriptionId, subscriptionCreated, level)). - succeedsWithin(DEFAULT_TIMEOUT); - assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { - assertThat(getResult).isNotNull(); - assertThat(getResult.type).isEqualTo(FOUND); - assertThat(getResult.record).isNotNull().satisfies(record -> { - assertThat(record.accessedAt).isEqualTo(subscriptionCreated); - assertThat(record.subscriptionId).isEqualTo(subscriptionId); - assertThat(record.subscriptionCreatedAt).isEqualTo(subscriptionCreated); - assertThat(record.subscriptionLevel).isEqualTo(level); - assertThat(record.subscriptionLevelChangedAt).isEqualTo(subscriptionCreated); - }); - }); - } - - @Test - void testSubscriptionLevelChanged() { - Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500); - long level = 1776; - String updatedSubscriptionId = "new"; - assertThat(subscriptionManager.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT); - assertThat(subscriptionManager.subscriptionCreated(user, "original", created, level - 1)).succeedsWithin( - DEFAULT_TIMEOUT); - assertThat(subscriptionManager.subscriptionLevelChanged(user, at, level, updatedSubscriptionId)).succeedsWithin( - DEFAULT_TIMEOUT); - assertThat(subscriptionManager.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> { - assertThat(getResult).isNotNull(); - assertThat(getResult.type).isEqualTo(FOUND); - assertThat(getResult.record).isNotNull().satisfies(record -> { - assertThat(record.accessedAt).isEqualTo(at); - assertThat(record.subscriptionLevelChangedAt).isEqualTo(at); - assertThat(record.subscriptionLevel).isEqualTo(level); - assertThat(record.subscriptionId).isEqualTo(updatedSubscriptionId); - }); - }); - } - - @Test - void testProcessorAndCustomerId() { - final ProcessorCustomer processorCustomer = - new ProcessorCustomer("abc", SubscriptionProcessor.STRIPE); - - assertThat(processorCustomer.toDynamoBytes()).isEqualTo(new byte[]{1, 97, 98, 99}); - } - - private static byte[] getRandomBytes(int length) { - byte[] result = new byte[length]; - SECURE_RANDOM.nextBytes(result); - return result; - } - - @Nonnull - private static Consumer checkFreshlyCreatedRecord( - byte[] user, byte[] password, Instant created) { - return record -> { - assertThat(record).isNotNull(); - assertThat(record.user).isEqualTo(user); - assertThat(record.password).isEqualTo(password); - assertThat(record.processorCustomer).isNull(); - assertThat(record.createdAt).isEqualTo(created); - assertThat(record.subscriptionId).isNull(); - assertThat(record.subscriptionCreatedAt).isNull(); - assertThat(record.subscriptionLevel).isNull(); - assertThat(record.subscriptionLevelChangedAt).isNull(); - assertThat(record.accessedAt).isEqualTo(created); - assertThat(record.canceledAt).isNull(); - assertThat(record.currentPeriodEndsAt).isNull(); - }; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java deleted file mode 100644 index 6728fde95..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationCodeStoreTest.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.storage; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.Arrays; -import java.util.Objects; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class VerificationCodeStoreTest { - - private VerificationCodeStore verificationCodeStore; - - private static final String TABLE_NAME = "verification_code_test"; - - private static final String PHONE_NUMBER = "+14151112222"; - - private static final long VALID_TIMESTAMP = Instant.now().toEpochMilli(); - private static final long EXPIRED_TIMESTAMP = Instant.now().minus(StoredVerificationCode.EXPIRATION).minus( - Duration.ofHours(1)).toEpochMilli(); - - @RegisterExtension - static final DynamoDbExtension DYNAMO_DB_EXTENSION = DynamoDbExtension.builder() - .tableName(TABLE_NAME) - .hashKey(VerificationCodeStore.KEY_E164) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(VerificationCodeStore.KEY_E164) - .attributeType(ScalarAttributeType.S) - .build()) - .build(); - - @BeforeEach - void setUp() { - verificationCodeStore = new VerificationCodeStore(DYNAMO_DB_EXTENSION.getDynamoDbClient(), TABLE_NAME); - } - - @Test - void testStoreAndFind() { - assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER)); - - final StoredVerificationCode originalCode = new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8)); - final StoredVerificationCode secondCode = new StoredVerificationCode("5678", VALID_TIMESTAMP, "efgh", "changed-session".getBytes(StandardCharsets.UTF_8)); - - verificationCodeStore.insert(PHONE_NUMBER, originalCode); - { - final Optional maybeCode = verificationCodeStore.findForNumber(PHONE_NUMBER); - - assertTrue(maybeCode.isPresent()); - assertTrue(storedVerificationCodesAreEqual(originalCode, maybeCode.get())); - } - - verificationCodeStore.insert(PHONE_NUMBER, secondCode); - { - final Optional maybeCode = verificationCodeStore.findForNumber(PHONE_NUMBER); - - assertTrue(maybeCode.isPresent()); - assertTrue(storedVerificationCodesAreEqual(secondCode, maybeCode.get())); - } - } - - @Test - void testRemove() { - assertEquals(Optional.empty(), verificationCodeStore.findForNumber(PHONE_NUMBER)); - - verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", VALID_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8))); - assertTrue(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent()); - - verificationCodeStore.remove(PHONE_NUMBER); - assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent()); - - verificationCodeStore.insert(PHONE_NUMBER, new StoredVerificationCode("1234", EXPIRED_TIMESTAMP, "abcd", "session".getBytes(StandardCharsets.UTF_8))); - assertFalse(verificationCodeStore.findForNumber(PHONE_NUMBER).isPresent()); - } - - private static boolean storedVerificationCodesAreEqual(final StoredVerificationCode first, final StoredVerificationCode second) { - if (first == null && second == null) { - return true; - } else if (first == null || second == null) { - return false; - } - - return Objects.equals(first.code(), second.code()) && - first.timestamp() == second.timestamp() && - Objects.equals(first.pushCode(), second.pushCode()) && - Arrays.equals(first.sessionId(), second.sessionId()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java deleted file mode 100644 index 131eee029..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.subscriptions; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation; -import java.math.BigDecimal; -import java.net.http.HttpHeaders; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import javax.ws.rs.ServiceUnavailableException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; - -class BraintreeGraphqlClientTest { - - private static final String CURRENCY = "xts"; - private static final String RETURN_URL = "https://example.com/return"; - private static final String CANCEL_URL = "https://example.com/cancel"; - private static final String LOCALE = "xx"; - - private FaultTolerantHttpClient httpClient; - private BraintreeGraphqlClient braintreeGraphqlClient; - - - @BeforeEach - void setUp() { - httpClient = mock(FaultTolerantHttpClient.class); - - braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, "https://example.com", "public", "super-secret"); - } - - @Test - void createPayPalOneTimePayment() { - - final HttpResponse response = mock(HttpResponse.class); - when(httpClient.sendAsync(any(), any())) - .thenReturn(CompletableFuture.completedFuture(response)); - - final String paymentId = "PAYID-AAA1AAAA1A11111AA111111A"; - when(response.body()) - .thenReturn(createPayPalOneTimePaymentResponse(paymentId)); - when(response.statusCode()) - .thenReturn(200); - - final CompletableFuture future = braintreeGraphqlClient.createPayPalOneTimePayment( - BigDecimal.ONE, CURRENCY, - RETURN_URL, CANCEL_URL, LOCALE); - - assertTimeoutPreemptively(Duration.ofSeconds(3), () -> { - final CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment result = future.get(); - - assertEquals(paymentId, result.paymentId); - assertNotNull(result.approvalUrl); - }); - } - - @Test - void createPayPalOneTimePaymentHttpError() { - - final HttpResponse response = mock(HttpResponse.class); - when(httpClient.sendAsync(any(), any())) - .thenReturn(CompletableFuture.completedFuture(response)); - - when(response.statusCode()) - .thenReturn(500); - final HttpHeaders httpheaders = mock(HttpHeaders.class); - when(httpheaders.firstValue(any())).thenReturn(Optional.empty()); - when(response.headers()) - .thenReturn(httpheaders); - - final CompletableFuture future = braintreeGraphqlClient.createPayPalOneTimePayment( - BigDecimal.ONE, CURRENCY, - RETURN_URL, CANCEL_URL, LOCALE); - - assertTimeoutPreemptively(Duration.ofSeconds(3), () -> { - - final ExecutionException e = assertThrows(ExecutionException.class, future::get); - - assertTrue(e.getCause() instanceof ServiceUnavailableException); - }); - } - - @Test - void createPayPalOneTimePaymentGraphQlError() { - - final HttpResponse response = mock(HttpResponse.class); - when(httpClient.sendAsync(any(), any())) - .thenReturn(CompletableFuture.completedFuture(response)); - - when(response.body()) - .thenReturn(createErrorResponse("createPayPalOneTimePayment", "12345")); - when(response.statusCode()) - .thenReturn(200); - - final CompletableFuture future = braintreeGraphqlClient.createPayPalOneTimePayment( - BigDecimal.ONE, CURRENCY, - RETURN_URL, CANCEL_URL, LOCALE); - - assertTimeoutPreemptively(Duration.ofSeconds(3), () -> { - - final ExecutionException e = assertThrows(ExecutionException.class, future::get); - assertTrue(e.getCause() instanceof ServiceUnavailableException); - }); - } - - private String createPayPalOneTimePaymentResponse(final String paymentId) { - final String cannedToken = "EC-1AA11111AA111111A"; - return String.format(""" - { - "data": { - "createPayPalOneTimePayment": { - "approvalUrl": "https://www.sandbox.paypal.com/checkoutnow?nolegacy=1&token=%2$s", - "paymentId": "%1$s" - } - }, - "extensions": { - "requestId": "%3$s" - } - } - """, paymentId, cannedToken, UUID.randomUUID()); - } - - private String createErrorResponse(final String operationName, final String legacyCode) { - return String.format(""" - { - "data": { - "%1$s": null - }, - "errors": [ { - "message": "This is a test error message.", - "locations": [ { - "line": 2, - "column": 7 - } ], - "path": [ "%1$s" ], - "extensions": { - "errorType": "user_error", - "errorClass": "VALIDATION", - "legacyCode": "%2$s", - "inputPath": [ "input", "testField" ] - } - }], - "extensions": { - "requestId": "%3$s" - } - } - """, operationName, legacyCode, UUID.randomUUID()); - } - - @Test - void tokenizePayPalOneTimePayment() { - } - - @Test - void chargeOneTimePayment() { - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsGeneratorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsGeneratorTest.java deleted file mode 100644 index f492a5c1c..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsGeneratorTest.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.auth; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.MutableClock; - -class ExternalServiceCredentialsGeneratorTest { - - private static final String E164 = "+14152222222"; - - private static final long TIME_SECONDS = 12345; - - private static final long TIME_MILLIS = TimeUnit.SECONDS.toMillis(TIME_SECONDS); - - private static final String TIME_SECONDS_STRING = Long.toString(TIME_SECONDS); - - - @Test - void testGenerateDerivedUsername() { - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .withUserDerivationKey(new byte[32]) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - assertNotEquals(credentials.username(), E164); - assertFalse(credentials.password().startsWith(E164)); - assertEquals(credentials.password().split(":").length, 3); - } - - @Test - void testGenerateNoDerivedUsername() { - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - assertEquals(credentials.username(), E164); - assertTrue(credentials.password().startsWith(E164)); - assertEquals(credentials.password().split(":").length, 3); - } - - @Test - public void testNotPrependUsername() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .prependUsername(false) - .withClock(clock) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - assertEquals(credentials.username(), E164); - assertTrue(credentials.password().startsWith(TIME_SECONDS_STRING)); - assertEquals(credentials.password().split(":").length, 2); - } - - @Test - public void testValidateValid() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .withClock(clock) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - assertEquals(generator.validateAndGetTimestamp(credentials).orElseThrow(), TIME_SECONDS); - } - - @Test - public void testValidateInvalid() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .withClock(clock) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - - final ExternalServiceCredentials corruptedUsername = new ExternalServiceCredentials( - credentials.username(), credentials.password().replace(E164, E164 + "0")); - final ExternalServiceCredentials corruptedTimestamp = new ExternalServiceCredentials( - credentials.username(), credentials.password().replace(TIME_SECONDS_STRING, TIME_SECONDS_STRING + "0")); - final ExternalServiceCredentials corruptedPassword = new ExternalServiceCredentials( - credentials.username(), credentials.password() + "0"); - - assertTrue(generator.validateAndGetTimestamp(corruptedUsername).isEmpty()); - assertTrue(generator.validateAndGetTimestamp(corruptedTimestamp).isEmpty()); - assertTrue(generator.validateAndGetTimestamp(corruptedPassword).isEmpty()); - } - - @Test - public void testValidateWithExpiration() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .withClock(clock) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - - final long elapsedSeconds = 10000; - clock.incrementSeconds(elapsedSeconds); - - assertEquals(generator.validateAndGetTimestamp(credentials, elapsedSeconds + 1).orElseThrow(), TIME_SECONDS); - assertTrue(generator.validateAndGetTimestamp(credentials, elapsedSeconds - 1).isEmpty()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/OptionalAccessTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/OptionalAccessTest.java deleted file mode 100644 index b907bd2a9..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/OptionalAccessTest.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.auth; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Base64; -import java.util.Optional; -import javax.ws.rs.WebApplicationException; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.auth.Anonymous; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.storage.Account; - -class OptionalAccessTest { - - @Test - void testUnidentifiedMissingTarget() { - try { - OptionalAccess.verify(Optional.empty(), Optional.empty(), Optional.empty()); - throw new AssertionError("should fail"); - } catch (WebApplicationException e) { - assertEquals(e.getResponse().getStatus(), 401); - } - } - - @Test - void testUnidentifiedMissingTargetDevice() { - Account account = mock(Account.class); - when(account.isEnabled()).thenReturn(true); - when(account.getDevice(eq(10))).thenReturn(Optional.empty()); - when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of("1234".getBytes())); - - try { - OptionalAccess.verify(Optional.empty(), Optional.of(new Anonymous(Base64.getEncoder().encodeToString("1234".getBytes()))), Optional.of(account), "10"); - } catch (WebApplicationException e) { - assertEquals(e.getResponse().getStatus(), 401); - } - } - - @Test - void testUnidentifiedBadTargetDevice() { - Account account = mock(Account.class); - when(account.isEnabled()).thenReturn(true); - when(account.getDevice(eq(10))).thenReturn(Optional.empty()); - when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of("1234".getBytes())); - - try { - OptionalAccess.verify(Optional.empty(), Optional.of(new Anonymous(Base64.getEncoder().encodeToString("1234".getBytes()))), Optional.of(account), "$$"); - } catch (WebApplicationException e) { - assertEquals(e.getResponse().getStatus(), 422); - } - } - - - @Test - void testUnidentifiedBadCode() { - Account account = mock(Account.class); - when(account.isEnabled()).thenReturn(true); - when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of("1234".getBytes())); - - try { - OptionalAccess.verify(Optional.empty(), Optional.of(new Anonymous(Base64.getEncoder().encodeToString("5678".getBytes()))), Optional.of(account)); - throw new AssertionError("should fail"); - } catch (WebApplicationException e) { - assertEquals(e.getResponse().getStatus(), 401); - } - } - - @Test - void testIdentifiedMissingTarget() { - Account account = mock(Account.class); - when(account.isEnabled()).thenReturn(true); - - try { - OptionalAccess.verify(Optional.of(account), Optional.empty(), Optional.empty()); - throw new AssertionError("should fail"); - } catch (WebApplicationException e) { - assertEquals(e.getResponse().getStatus(), 404); - } - } - - @Test - void testUnsolicitedBadTarget() { - Account account = mock(Account.class); - when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false); - when(account.isEnabled()).thenReturn(true); - - try { - OptionalAccess.verify(Optional.empty(), Optional.empty(), Optional.of(account)); - throw new AssertionError("should fail"); - } catch (WebApplicationException e) { - assertEquals(e.getResponse().getStatus(), 401); - } - } - - @Test - void testUnsolicitedGoodTarget() { - Account account = mock(Account.class); - Anonymous random = mock(Anonymous.class); - when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(true); - when(account.isEnabled()).thenReturn(true); - OptionalAccess.verify(Optional.empty(), Optional.of(random), Optional.of(account)); - } - - @Test - void testUnidentifiedGoodTarget() { - Account account = mock(Account.class); - when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of("1234".getBytes())); - when(account.isEnabled()).thenReturn(true); - OptionalAccess.verify(Optional.empty(), Optional.of(new Anonymous(Base64.getEncoder().encodeToString("1234".getBytes()))), Optional.of(account)); - } - - @Test - void testUnidentifiedInactive() { - Account account = mock(Account.class); - when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of("1234".getBytes())); - when(account.isEnabled()).thenReturn(false); - - try { - OptionalAccess.verify(Optional.empty(), Optional.of(new Anonymous(Base64.getEncoder().encodeToString("1234".getBytes()))), Optional.of(account)); - throw new AssertionError(); - } catch (WebApplicationException e) { - assertEquals(e.getResponse().getStatus(), 401); - } - } - - @Test - void testIdentifiedGoodTarget() { - Account source = mock(Account.class); - Account target = mock(Account.class); - when(target.isEnabled()).thenReturn(true); - OptionalAccess.verify(Optional.of(source), Optional.empty(), Optional.of(target)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/SaltedTokenHashTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/SaltedTokenHashTest.java deleted file mode 100644 index 55f83e5d0..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/SaltedTokenHashTest.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.auth; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; - -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; - -class SaltedTokenHashTest { - - @Test - void testCreating() { - SaltedTokenHash credentials = SaltedTokenHash.generateFor("mypassword"); - assertThat(credentials.salt()).isNotEmpty(); - assertThat(credentials.hash()).isNotEmpty(); - assertThat(credentials.hash().length()).isEqualTo(66); - } - - @Test - void testMatching() { - SaltedTokenHash credentials = SaltedTokenHash.generateFor("mypassword"); - - SaltedTokenHash provided = new SaltedTokenHash(credentials.hash(), credentials.salt()); - assertThat(provided.verify("mypassword")).isTrue(); - } - - @Test - void testMisMatching() { - SaltedTokenHash credentials = SaltedTokenHash.generateFor("mypassword"); - - SaltedTokenHash provided = new SaltedTokenHash(credentials.hash(), credentials.salt()); - assertThat(provided.verify("wrong")).isFalse(); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ArtControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ArtControllerTest.java deleted file mode 100644 index c146ce8cc..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/ArtControllerTest.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; -import org.whispersystems.textsecuregcm.controllers.ArtController; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class ArtControllerTest { - - private static final ArtServiceConfiguration ART_SERVICE_CONFIGURATION = MockUtils.buildMock( - ArtServiceConfiguration.class, - cfg -> { - Mockito.when(cfg.getUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32]); - Mockito.when(cfg.getUserAuthenticationTokenUserIdSecret()).thenReturn(new byte[32]); - }); - private static final ExternalServiceCredentialsGenerator artCredentialsGenerator = ArtController.credentialsGenerator(ART_SERVICE_CONFIGURATION); - private static final RateLimiter rateLimiter = mock(RateLimiter.class); - private static final RateLimiters rateLimiters = mock(RateLimiters.class); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new ArtController(rateLimiters, artCredentialsGenerator)) - .build(); - - @Test - void testGetAuthToken() { - when(rateLimiters.getArtPackLimiter()).thenReturn(rateLimiter); - - ExternalServiceCredentials token = - resources.getJerseyTest() - .target("/v1/art/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(ExternalServiceCredentials.class); - - assertThat(token.password()).isNotEmpty(); - assertThat(token.username()).isNotEmpty(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AttachmentControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AttachmentControllerTest.java deleted file mode 100644 index 5e7ecb85c..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/AttachmentControllerTest.java +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.io.IOException; -import java.net.MalformedURLException; -import java.net.URL; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import javax.ws.rs.core.Response; -import org.assertj.core.api.Assertions; -import org.assertj.core.api.Condition; -import org.assertj.core.api.InstanceOfAssertFactories; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; -import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; -import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV2; -import org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class AttachmentControllerTest { - - private static RateLimiters rateLimiters = mock(RateLimiters.class ); - private static RateLimiter rateLimiter = mock(RateLimiter.class ); - - static { - when(rateLimiters.getAttachmentLimiter()).thenReturn(rateLimiter); - } - - public static final String RSA_PRIVATE_KEY_PEM; - - static { - try { - final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - keyPairGenerator.initialize(1024); - final KeyPair keyPair = keyPairGenerator.generateKeyPair(); - - RSA_PRIVATE_KEY_PEM = "-----BEGIN PRIVATE KEY-----\n" + - Base64.getMimeEncoder().encodeToString(keyPair.getPrivate().getEncoded()) + "\n" + - "-----END PRIVATE KEY-----"; - } catch (NoSuchAlgorithmException e) { - throw new AssertionError(e); - } - } - - private static final ResourceExtension resources; - - static { - try { - resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new AttachmentControllerV2(rateLimiters, "accessKey", "accessSecret", "us-east-1", "attachmentv2-bucket")) - .addResource(new AttachmentControllerV3(rateLimiters, "some-cdn.signal.org", "signal@example.com", 1000, "/attach-here", RSA_PRIVATE_KEY_PEM)) - .build(); - } catch (IOException | InvalidKeyException | InvalidKeySpecException e) { - throw new AssertionError(e); - } - } - - @Test - void testV3Form() { - AttachmentDescriptorV3 descriptor = resources.getJerseyTest() - .target("/v3/attachments/form/upload") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(AttachmentDescriptorV3.class); - - assertThat(descriptor.getKey()).isNotBlank(); - assertThat(descriptor.getCdn()).isEqualTo(2); - assertThat(descriptor.getHeaders()).hasSize(3); - assertThat(descriptor.getHeaders()).extractingByKey("host").isEqualTo("some-cdn.signal.org"); - assertThat(descriptor.getHeaders()).extractingByKey("x-goog-resumable").isEqualTo("start"); - assertThat(descriptor.getHeaders()).extractingByKey("x-goog-content-length-range").isEqualTo("1,1000"); - assertThat(descriptor.getSignedUploadLocation()).isNotEmpty(); - assertThat(descriptor.getSignedUploadLocation()).contains("X-Goog-Signature"); - assertThat(descriptor.getSignedUploadLocation()).is(new Condition<>(x -> { - try { - new URL(x); - } catch (MalformedURLException e) { - return false; - } - return true; - }, "convertible to a URL", (Object[]) null)); - - final URL signedUploadLocation; - try { - signedUploadLocation = new URL(descriptor.getSignedUploadLocation()); - } catch (MalformedURLException e) { - throw new AssertionError(e); - } - assertThat(signedUploadLocation.getHost()).isEqualTo("some-cdn.signal.org"); - assertThat(signedUploadLocation.getPath()).startsWith("/attach-here/"); - final Map queryParamMap = new HashMap<>(); - final String[] queryTerms = signedUploadLocation.getQuery().split("&"); - for (final String queryTerm : queryTerms) { - final String[] keyValueArray = queryTerm.split("=", 2); - queryParamMap.put( - URLDecoder.decode(keyValueArray[0], StandardCharsets.UTF_8), - URLDecoder.decode(keyValueArray[1], StandardCharsets.UTF_8)); - } - - assertThat(queryParamMap).extractingByKey("X-Goog-Algorithm").isEqualTo("GOOG4-RSA-SHA256"); - assertThat(queryParamMap).extractingByKey("X-Goog-Expires").isEqualTo("90000"); - assertThat(queryParamMap).extractingByKey("X-Goog-SignedHeaders").isEqualTo("host;x-goog-content-length-range;x-goog-resumable"); - assertThat(queryParamMap).extractingByKey("X-Goog-Date", Assertions.as(InstanceOfAssertFactories.STRING)).isNotEmpty(); - - final String credential = queryParamMap.get("X-Goog-Credential"); - String[] credentialParts = credential.split("/"); - assertThat(credentialParts).hasSize(5); - assertThat(credentialParts[0]).isEqualTo("signal@example.com"); - assertThat(credentialParts[2]).isEqualTo("auto"); - assertThat(credentialParts[3]).isEqualTo("storage"); - assertThat(credentialParts[4]).isEqualTo("goog4_request"); - } - - @Test - void testV3FormDisabled() { - Response response = resources.getJerseyTest() - .target("/v3/attachments/form/upload") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testV2Form() throws IOException { - AttachmentDescriptorV2 descriptor = resources.getJerseyTest() - .target("/v2/attachments/form/upload") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(AttachmentDescriptorV2.class); - - assertThat(descriptor.getKey()).isEqualTo(descriptor.getAttachmentIdString()); - assertThat(descriptor.getAcl()).isEqualTo("private"); - assertThat(descriptor.getAlgorithm()).isEqualTo("AWS4-HMAC-SHA256"); - assertThat(descriptor.getAttachmentId()).isGreaterThan(0); - assertThat(String.valueOf(descriptor.getAttachmentId())).isEqualTo(descriptor.getAttachmentIdString()); - - String[] credentialParts = descriptor.getCredential().split("/"); - - assertThat(credentialParts[0]).isEqualTo("accessKey"); - assertThat(credentialParts[2]).isEqualTo("us-east-1"); - assertThat(credentialParts[3]).isEqualTo("s3"); - assertThat(credentialParts[4]).isEqualTo("aws4_request"); - - assertThat(descriptor.getDate()).isNotBlank(); - assertThat(descriptor.getPolicy()).isNotBlank(); - assertThat(descriptor.getSignature()).isNotBlank(); - - assertThat(new String(Base64.getDecoder().decode(descriptor.getPolicy()))).contains("[\"content-length-range\", 1, 104857600]"); - } - - @Test - void testV2FormDisabled() { - Response response = resources.getJerseyTest() - .target("/v2/attachments/form/upload") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CertificateControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CertificateControllerTest.java deleted file mode 100644 index 9f8aa8c4d..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CertificateControllerTest.java +++ /dev/null @@ -1,420 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.io.IOException; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.time.temporal.ChronoUnit; -import java.util.Base64; -import java.util.stream.Stream; -import javax.ws.rs.core.Response; -import org.apache.commons.lang3.StringUtils; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.signal.libsignal.protocol.ecc.Curve; -import org.signal.libsignal.zkgroup.ServerSecretParams; -import org.signal.libsignal.zkgroup.VerificationFailedException; -import org.signal.libsignal.zkgroup.auth.AuthCredentialResponse; -import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse; -import org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations; -import org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.CertificateGenerator; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.controllers.CertificateController; -import org.whispersystems.textsecuregcm.entities.DeliveryCertificate; -import org.whispersystems.textsecuregcm.entities.GroupCredentials; -import org.whispersystems.textsecuregcm.entities.MessageProtos.SenderCertificate; -import org.whispersystems.textsecuregcm.entities.MessageProtos.ServerCertificate; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; -import org.whispersystems.textsecuregcm.util.Util; - -@ExtendWith(DropwizardExtensionsSupport.class) -class CertificateControllerTest { - - private static final String caPublicKey = "BWh+UOhT1hD8bkb+MFRvb6tVqhoG8YYGCzOd7mgjo8cV"; - - @SuppressWarnings("unused") - private static final String caPrivateKey = "EO3Mnf0kfVlVnwSaqPoQnAxhnnGL1JTdXqktCKEe9Eo="; - - private static final String signingCertificate = "CiUIDBIhBbTz4h1My+tt+vw+TVscgUe/DeHS0W02tPWAWbTO2xc3EkD+go4bJnU0AcnFfbOLKoiBfCzouZtDYMOVi69rE7r4U9cXREEqOkUmU2WJBjykAxWPCcSTmVTYHDw7hkSp/puG"; - private static final String signingKey = "ABOxG29xrfq4E7IrW11Eg7+HBbtba9iiS0500YoBjn4="; - - private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate(); - private static final CertificateGenerator certificateGenerator; - private static final ServerZkAuthOperations serverZkAuthOperations; - private static final Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); - - static { - try { - certificateGenerator = new CertificateGenerator(Base64.getDecoder().decode(signingCertificate), - Curve.decodePrivatePoint(Base64.getDecoder().decode(signingKey)), 1); - serverZkAuthOperations = new ServerZkAuthOperations(serverSecretParams); - } catch (IOException e) { - throw new AssertionError(e); - } - } - - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new CertificateController(certificateGenerator, serverZkAuthOperations, clock)) - .build(); - - @Test - void testValidCertificate() throws Exception { - DeliveryCertificate certificateObject = resources.getJerseyTest() - .target("/v1/certificate/delivery") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(DeliveryCertificate.class); - - SenderCertificate certificateHolder = SenderCertificate.parseFrom(certificateObject.getCertificate()); - SenderCertificate.Certificate certificate = SenderCertificate.Certificate.parseFrom( - certificateHolder.getCertificate()); - - ServerCertificate serverCertificateHolder = certificate.getSigner(); - ServerCertificate.Certificate serverCertificate = ServerCertificate.Certificate.parseFrom( - serverCertificateHolder.getCertificate()); - - assertTrue(Curve.verifySignature(Curve.decodePoint(serverCertificate.getKey().toByteArray(), 0), - certificateHolder.getCertificate().toByteArray(), certificateHolder.getSignature().toByteArray())); - assertTrue(Curve.verifySignature(Curve.decodePoint(Base64.getDecoder().decode(caPublicKey), 0), - serverCertificateHolder.getCertificate().toByteArray(), serverCertificateHolder.getSignature().toByteArray())); - - assertEquals(certificate.getSender(), AuthHelper.VALID_NUMBER); - assertEquals(certificate.getSenderDevice(), 1L); - assertTrue(certificate.hasSenderUuid()); - assertEquals(AuthHelper.VALID_UUID.toString(), certificate.getSenderUuid()); - assertArrayEquals(certificate.getIdentityKey().toByteArray(), - Base64.getDecoder().decode(AuthHelper.VALID_IDENTITY)); - } - - @Test - void testValidCertificateWithUuid() throws Exception { - DeliveryCertificate certificateObject = resources.getJerseyTest() - .target("/v1/certificate/delivery") - .queryParam("includeUuid", "true") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(DeliveryCertificate.class); - - SenderCertificate certificateHolder = SenderCertificate.parseFrom(certificateObject.getCertificate()); - SenderCertificate.Certificate certificate = SenderCertificate.Certificate.parseFrom( - certificateHolder.getCertificate()); - - ServerCertificate serverCertificateHolder = certificate.getSigner(); - ServerCertificate.Certificate serverCertificate = ServerCertificate.Certificate.parseFrom( - serverCertificateHolder.getCertificate()); - - assertTrue(Curve.verifySignature(Curve.decodePoint(serverCertificate.getKey().toByteArray(), 0), - certificateHolder.getCertificate().toByteArray(), certificateHolder.getSignature().toByteArray())); - assertTrue(Curve.verifySignature(Curve.decodePoint(Base64.getDecoder().decode(caPublicKey), 0), - serverCertificateHolder.getCertificate().toByteArray(), serverCertificateHolder.getSignature().toByteArray())); - - assertEquals(certificate.getSender(), AuthHelper.VALID_NUMBER); - assertEquals(certificate.getSenderDevice(), 1L); - assertEquals(certificate.getSenderUuid(), AuthHelper.VALID_UUID.toString()); - assertArrayEquals(certificate.getIdentityKey().toByteArray(), - Base64.getDecoder().decode(AuthHelper.VALID_IDENTITY)); - } - - @Test - void testValidCertificateWithUuidNoE164() throws Exception { - DeliveryCertificate certificateObject = resources.getJerseyTest() - .target("/v1/certificate/delivery") - .queryParam("includeUuid", "true") - .queryParam("includeE164", "false") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(DeliveryCertificate.class); - - SenderCertificate certificateHolder = SenderCertificate.parseFrom(certificateObject.getCertificate()); - SenderCertificate.Certificate certificate = SenderCertificate.Certificate.parseFrom( - certificateHolder.getCertificate()); - - ServerCertificate serverCertificateHolder = certificate.getSigner(); - ServerCertificate.Certificate serverCertificate = ServerCertificate.Certificate.parseFrom( - serverCertificateHolder.getCertificate()); - - assertTrue(Curve.verifySignature(Curve.decodePoint(serverCertificate.getKey().toByteArray(), 0), - certificateHolder.getCertificate().toByteArray(), certificateHolder.getSignature().toByteArray())); - assertTrue(Curve.verifySignature(Curve.decodePoint(Base64.getDecoder().decode(caPublicKey), 0), - serverCertificateHolder.getCertificate().toByteArray(), serverCertificateHolder.getSignature().toByteArray())); - - assertTrue(StringUtils.isBlank(certificate.getSender())); - assertEquals(certificate.getSenderDevice(), 1L); - assertEquals(certificate.getSenderUuid(), AuthHelper.VALID_UUID.toString()); - assertArrayEquals(certificate.getIdentityKey().toByteArray(), - Base64.getDecoder().decode(AuthHelper.VALID_IDENTITY)); - } - - @Test - void testBadAuthentication() { - Response response = resources.getJerseyTest() - .target("/v1/certificate/delivery") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) - .get(); - - assertEquals(response.getStatus(), 401); - } - - - @Test - void testNoAuthentication() { - Response response = resources.getJerseyTest() - .target("/v1/certificate/delivery") - .request() - .get(); - - assertEquals(response.getStatus(), 401); - } - - - @Test - void testUnidentifiedAuthentication() { - Response response = resources.getJerseyTest() - .target("/v1/certificate/delivery") - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1234".getBytes())) - .get(); - - assertEquals(response.getStatus(), 401); - } - - @Test - void testDisabledAuthentication() { - Response response = resources.getJerseyTest() - .target("/v1/certificate/delivery") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .get(); - - assertEquals(response.getStatus(), 401); - } - - @Test - void testGetSingleAuthCredential() { - GroupCredentials credentials = resources.getJerseyTest() - .target("/v1/certificate/group/" + currentDaysSinceEpoch() + "/" + currentDaysSinceEpoch()) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(GroupCredentials.class); - - assertThat(credentials.credentials().size()).isEqualTo(1); - assertThat(credentials.credentials().get(0).redemptionTime()).isEqualTo(currentDaysSinceEpoch()); - - ClientZkAuthOperations clientZkAuthOperations = new ClientZkAuthOperations(serverSecretParams.getPublicParams()); - - assertThatCode(() -> - clientZkAuthOperations.receiveAuthCredential(AuthHelper.VALID_UUID, currentDaysSinceEpoch(), - new AuthCredentialResponse(credentials.credentials().get(0).credential()))) - .doesNotThrowAnyException(); - } - - @Test - void testGetSingleAuthCredentialByPni() { - GroupCredentials credentials = resources.getJerseyTest() - .target("/v1/certificate/group/" + currentDaysSinceEpoch() + "/" + currentDaysSinceEpoch()) - .queryParam("identity", "pni") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(GroupCredentials.class); - - assertThat(credentials.credentials().size()).isEqualTo(1); - assertThat(credentials.credentials().get(0).redemptionTime()).isEqualTo(currentDaysSinceEpoch()); - - ClientZkAuthOperations clientZkAuthOperations = new ClientZkAuthOperations(serverSecretParams.getPublicParams()); - - assertThatExceptionOfType(VerificationFailedException.class) - .isThrownBy(() -> - clientZkAuthOperations.receiveAuthCredential(AuthHelper.VALID_UUID, currentDaysSinceEpoch(), - new AuthCredentialResponse(credentials.credentials().get(0).credential()))); - } - - @Test - void testGetWeekLongAuthCredentials() { - GroupCredentials credentials = resources.getJerseyTest() - .target("/v1/certificate/group/" + currentDaysSinceEpoch() + "/" + (currentDaysSinceEpoch() + 7)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(GroupCredentials.class); - - assertThat(credentials.credentials().size()).isEqualTo(8); - - for (int i = 0; i <= 7; i++) { - assertThat(credentials.credentials().get(i).redemptionTime()).isEqualTo(currentDaysSinceEpoch() + i); - - ClientZkAuthOperations clientZkAuthOperations = new ClientZkAuthOperations(serverSecretParams.getPublicParams()); - - final int time = i; - - assertThatCode(() -> - clientZkAuthOperations.receiveAuthCredential(AuthHelper.VALID_UUID, currentDaysSinceEpoch() + time, - new AuthCredentialResponse(credentials.credentials().get(time).credential()))) - .doesNotThrowAnyException(); - } - } - - @Test - void testTooManyDaysOut() { - Response response = resources.getJerseyTest() - .target("/v1/certificate/group/" + currentDaysSinceEpoch() + "/" + (currentDaysSinceEpoch() + 8)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(400); - } - - @Test - void testBackwardsInTime() { - Response response = resources.getJerseyTest() - .target("/v1/certificate/group/" + (currentDaysSinceEpoch() - 1) + "/" + (currentDaysSinceEpoch() + 7)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(400); - } - - @Test - void testBadAuth() { - Response response = resources.getJerseyTest() - .target("/v1/certificate/group/" + currentDaysSinceEpoch() + "/" + (currentDaysSinceEpoch() + 7)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testGetSingleGroupCredential() { - final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS); - - final GroupCredentials credentials = resources.getJerseyTest() - .target("/v1/certificate/auth/group") - .queryParam("redemptionStartSeconds", startOfDay.getEpochSecond()) - .queryParam("redemptionEndSeconds", startOfDay.getEpochSecond()) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(GroupCredentials.class); - - assertEquals(1, credentials.credentials().size()); - assertEquals(AuthHelper.VALID_PNI, credentials.pni()); - assertEquals(startOfDay.getEpochSecond(), credentials.credentials().get(0).redemptionTime()); - - final ClientZkAuthOperations clientZkAuthOperations = - new ClientZkAuthOperations(serverSecretParams.getPublicParams()); - - assertDoesNotThrow(() -> { - clientZkAuthOperations.receiveAuthCredentialWithPni( - AuthHelper.VALID_UUID, - AuthHelper.VALID_PNI, - (int) startOfDay.getEpochSecond(), - new AuthCredentialWithPniResponse(credentials.credentials().get(0).credential())); - }); - } - - @Test - void testGetWeekLongGroupCredentials() { - final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS); - - final GroupCredentials credentials = resources.getJerseyTest() - .target("/v1/certificate/auth/group") - .queryParam("redemptionStartSeconds", startOfDay.getEpochSecond()) - .queryParam("redemptionEndSeconds", startOfDay.plus(Duration.ofDays(7)).getEpochSecond()) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(GroupCredentials.class); - - assertEquals(AuthHelper.VALID_PNI, credentials.pni()); - assertEquals(8, credentials.credentials().size()); - - final ClientZkAuthOperations clientZkAuthOperations = - new ClientZkAuthOperations(serverSecretParams.getPublicParams()); - - for (int i = 0; i < 8; i++) { - final Instant redemptionTime = startOfDay.plus(Duration.ofDays(i)); - assertEquals(redemptionTime.getEpochSecond(), credentials.credentials().get(i).redemptionTime()); - - final int index = i; - - assertDoesNotThrow(() -> { - clientZkAuthOperations.receiveAuthCredentialWithPni( - AuthHelper.VALID_UUID, - AuthHelper.VALID_PNI, - redemptionTime.getEpochSecond(), - new AuthCredentialWithPniResponse(credentials.credentials().get(index).credential())); - }); - } - } - - @ParameterizedTest - @MethodSource - void testBadRedemptionTimes(final Instant redemptionStart, final Instant redemptionEnd) { - final Response response = resources.getJerseyTest() - .target("/v1/certificate/auth/group") - .queryParam("redemptionStartSeconds", redemptionStart.getEpochSecond()) - .queryParam("redemptionEndSeconds", redemptionEnd.getEpochSecond()) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertEquals(400, response.getStatus()); - } - - private static Stream testBadRedemptionTimes() { - return Stream.of( - // Start is after end - Arguments.of(clock.instant().plus(Duration.ofDays(1)), clock.instant()), - - // Start is in the past - Arguments.of(clock.instant().minus(Duration.ofDays(1)), clock.instant()), - - // End is too far in the future - Arguments.of(clock.instant(), - clock.instant().plus(CertificateController.MAX_REDEMPTION_DURATION).plus(Duration.ofDays(1))), - - // Start is not at a day boundary - Arguments.of(clock.instant().plusSeconds(17), clock.instant().plus(Duration.ofDays(1))), - - // End is not at a day boundary - Arguments.of(clock.instant(), clock.instant().plusSeconds(17)) - ); - } - - private static int currentDaysSinceEpoch() { - return Util.currentDaysSinceEpoch(Clock.systemUTC()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java deleted file mode 100644 index 4b910eb38..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DeviceControllerTest.java +++ /dev/null @@ -1,512 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import com.google.common.net.HttpHeaders; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Stream; -import javax.ws.rs.Path; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.StoredVerificationCode; -import org.whispersystems.textsecuregcm.auth.WebsocketRefreshApplicationEventListener; -import org.whispersystems.textsecuregcm.controllers.DeviceController; -import org.whispersystems.textsecuregcm.entities.AccountAttributes; -import org.whispersystems.textsecuregcm.entities.DeviceResponse; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.StoredVerificationCodeManager; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.VerificationCode; - -@ExtendWith(DropwizardExtensionsSupport.class) -class DeviceControllerTest { - - @Path("/v1/devices") - static class DumbVerificationDeviceController extends DeviceController { - - public DumbVerificationDeviceController(StoredVerificationCodeManager pendingDevices, - AccountsManager accounts, - MessagesManager messages, - Keys keys, - RateLimiters rateLimiters, - Map deviceConfiguration) { - super(pendingDevices, accounts, messages, keys, rateLimiters, deviceConfiguration); - } - - @Override - protected VerificationCode generateVerificationCode() { - return new VerificationCode(5678901); - } - } - - private static StoredVerificationCodeManager pendingDevicesManager = mock(StoredVerificationCodeManager.class); - private static AccountsManager accountsManager = mock(AccountsManager.class); - private static MessagesManager messagesManager = mock(MessagesManager.class); - private static Keys keys = mock(Keys.class); - private static RateLimiters rateLimiters = mock(RateLimiters.class); - private static RateLimiter rateLimiter = mock(RateLimiter.class); - private static Account account = mock(Account.class); - private static Account maxedAccount = mock(Account.class); - private static Device masterDevice = mock(Device.class); - private static ClientPresenceManager clientPresenceManager = mock(ClientPresenceManager.class); - - private static Map deviceConfiguration = new HashMap<>(); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addProvider(new WebsocketRefreshApplicationEventListener(accountsManager, clientPresenceManager)) - .addProvider(new DeviceLimitExceededExceptionMapper()) - .addResource(new DumbVerificationDeviceController(pendingDevicesManager, - accountsManager, - messagesManager, - keys, - rateLimiters, - deviceConfiguration)) - .build(); - - - @BeforeEach - void setup() { - when(rateLimiters.getSmsDestinationLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getVoiceDestinationLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getVerifyLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getAllocateDeviceLimiter()).thenReturn(rateLimiter); - when(rateLimiters.getVerifyDeviceLimiter()).thenReturn(rateLimiter); - - when(masterDevice.getId()).thenReturn(1L); - - when(account.getNextDeviceId()).thenReturn(42L); - when(account.getNumber()).thenReturn(AuthHelper.VALID_NUMBER); - when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID); - when(account.isEnabled()).thenReturn(false); - when(account.isSenderKeySupported()).thenReturn(true); - when(account.isAnnouncementGroupSupported()).thenReturn(true); - when(account.isChangeNumberSupported()).thenReturn(true); - when(account.isPniSupported()).thenReturn(true); - when(account.isStoriesSupported()).thenReturn(true); - when(account.isGiftBadgesSupported()).thenReturn(true); - when(account.isPaymentActivationSupported()).thenReturn(false); - - when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER)).thenReturn( - Optional.of(new StoredVerificationCode("5678901", System.currentTimeMillis(), null, null))); - when(pendingDevicesManager.getCodeForNumber(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.empty()); - when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(account)); - when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(maxedAccount)); - - AccountsHelper.setupMockUpdate(accountsManager); - } - - @AfterEach - void teardown() { - reset( - pendingDevicesManager, - accountsManager, - messagesManager, - keys, - rateLimiters, - rateLimiter, - account, - maxedAccount, - masterDevice, - clientPresenceManager - ); - } - - @Test - void validDeviceRegisterTest() { - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT)); - - final Device existingDevice = mock(Device.class); - when(existingDevice.getId()).thenReturn(Device.MASTER_ID); - when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(existingDevice)); - - VerificationCode deviceCode = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VerificationCode.class); - - assertThat(deviceCode).isEqualTo(new VerificationCode(5678901)); - - DeviceResponse response = resources.getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, "password1")) - .put(Entity.entity(new AccountAttributes(false, 1234, null, - null, true, null), - MediaType.APPLICATION_JSON_TYPE), - DeviceResponse.class); - - assertThat(response.getDeviceId()).isEqualTo(42L); - - verify(pendingDevicesManager).remove(AuthHelper.VALID_NUMBER); - verify(messagesManager).clear(eq(AuthHelper.VALID_UUID), eq(42L)); - verify(clientPresenceManager).disconnectPresence(AuthHelper.VALID_UUID, Device.MASTER_ID); - } - - @Test - void verifyDeviceWithNullAccountAttributes() { - when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT)); - - final Device existingDevice = mock(Device.class); - when(existingDevice.getId()).thenReturn(Device.MASTER_ID); - when(AuthHelper.VALID_ACCOUNT.getDevices()).thenReturn(List.of(existingDevice)); - - VerificationCode deviceCode = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VerificationCode.class); - - assertThat(deviceCode).isEqualTo(new VerificationCode(5678901)); - - final Response response = resources.getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, "password1")) - .put(Entity.json("")); - - assertThat(response.getStatus()).isNotEqualTo(500); - } - - @Test - void verifyDeviceTokenBadCredentials() { - final Response response = resources.getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", "This is not a valid authorization header") - .put(Entity.entity(new AccountAttributes(false, 1234, null, - null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertEquals(401, response.getStatus()); - } - - @Test - void disabledDeviceRegisterTest() { - Response response = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void invalidDeviceRegisterTest() { - VerificationCode deviceCode = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(VerificationCode.class); - - assertThat(deviceCode).isEqualTo(new VerificationCode(5678901)); - - Response response = resources.getJerseyTest() - .target("/v1/devices/5678902") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, "password1")) - .put(Entity.entity(new AccountAttributes(false, 1234, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(403); - - verifyNoMoreInteractions(messagesManager); - } - - @Test - void oldDeviceRegisterTest() { - Response response = resources.getJerseyTest() - .target("/v1/devices/1112223") - .request() - .header("Authorization", - AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .put(Entity.entity(new AccountAttributes(false, 1234, null, null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(403); - - verifyNoMoreInteractions(messagesManager); - } - - @Test - void maxDevicesTest() { - Response response = resources.getJerseyTest() - .target("/v1/devices/provisioning/code") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .get(); - - assertEquals(411, response.getStatus()); - verifyNoMoreInteractions(messagesManager); - } - - @Test - void longNameTest() { - Response response = resources.getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, "password1")) - .put(Entity.entity(new AccountAttributes(false, 1234, - "this is a really long name that is longer than 80 characters it's so long that it's even longer than 204 characters. that's a lot of characters. we're talking lots and lots and lots of characters. 12345678", - null, true, null), - MediaType.APPLICATION_JSON_TYPE)); - - assertEquals(response.getStatus(), 422); - verifyNoMoreInteractions(messagesManager); - } - - @Test - void deviceDowngradeSenderKeyTest() { - DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, false, true, - true, true, true, true, true); - AccountAttributes accountAttributes = - new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - Response response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(409); - - deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, true, true); - accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void deviceDowngradeAnnouncementGroupTest() { - DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, false, - true, true, true, true, true); - AccountAttributes accountAttributes = - new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - Response response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(409); - - deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, true, true); - accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void deviceDowngradeChangeNumberTest() { - DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, true, - false, true, true, true, true); - AccountAttributes accountAttributes = - new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - Response response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", - AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(409); - - deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, true, true); - accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", - AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void deviceDowngradePniTest() { - DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, - false, true, true, true); - AccountAttributes accountAttributes = - new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - Response response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(409); - - deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, true, true); - accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", - AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void deviceDowngradeStoriesTest() { - DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, - true, false, true, true); - AccountAttributes accountAttributes = - new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - Response response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", - AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(409); - - deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, true, true); - accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", - AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void deviceDowngradeGiftBadgesTest() { - DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, false, true); - AccountAttributes accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - Response response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(409); - - deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, true, true); - accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", - AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void deviceDowngradePaymentActivationTest(boolean paymentActivation) { - // Update when we start returning true value of capability & restricting downgrades - DeviceCapabilities deviceCapabilities = new DeviceCapabilities(true, true, true, true, true, true, true, true, paymentActivation); - AccountAttributes accountAttributes = new AccountAttributes(false, 1234, null, null, true, deviceCapabilities); - Response response = resources - .getJerseyTest() - .target("/v1/devices/5678901") - .request() - .header("Authorization", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .put(Entity.entity(accountAttributes, MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(200); - } - - @Test - void deviceRemovalClearsMessagesAndKeys() { - - // this is a static mock, so it might have previous invocations - clearInvocations(AuthHelper.VALID_ACCOUNT); - - final long deviceId = 2; - - final Response response = resources - .getJerseyTest() - .target("/v1/devices/" + deviceId) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.USER_AGENT, "Signal-Android/5.42.8675309 Android/30") - .delete(); - - assertThat(response.getStatus()).isEqualTo(204); - - verify(messagesManager, times(2)).clear(AuthHelper.VALID_UUID, deviceId); - verify(accountsManager, times(1)).update(eq(AuthHelper.VALID_ACCOUNT), any()); - verify(AuthHelper.VALID_ACCOUNT).removeDevice(deviceId); - verify(keys).delete(AuthHelper.VALID_UUID, deviceId); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DirectoryControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DirectoryControllerTest.java deleted file mode 100644 index a0a08055a..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DirectoryControllerTest.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import com.google.common.net.HttpHeaders; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.util.Collections; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.Response.Status.Family; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.controllers.DirectoryController; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class DirectoryControllerTest { - - private static final ExternalServiceCredentialsGenerator directoryCredentialsGenerator = mock(ExternalServiceCredentialsGenerator.class); - private static final ExternalServiceCredentials validCredentials = new ExternalServiceCredentials("username", "password"); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new DirectoryController(directoryCredentialsGenerator)) - .build(); - - @BeforeEach - void setup() { - when(directoryCredentialsGenerator.generateFor(eq(AuthHelper.VALID_NUMBER))).thenReturn(validCredentials); - } - - @Test - void testFeedbackOk() { - Response response = - resources.getJerseyTest() - .target("/v1/directory/feedback-v3/ok") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.json("{\"reason\": \"test reason\"}")); - assertThat(response.getStatusInfo().getFamily()).isEqualTo(Family.SUCCESSFUL); - } - - @Test - void testGetAuthToken() { - ExternalServiceCredentials token = - resources.getJerseyTest() - .target("/v1/directory/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(ExternalServiceCredentials.class); - assertThat(token.username()).isEqualTo(validCredentials.username()); - assertThat(token.password()).isEqualTo(validCredentials.password()); - } - - @Test - void testDisabledGetAuthToken() { - Response response = - resources.getJerseyTest() - .target("/v1/directory/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .get(); - assertThat(response.getStatus()).isEqualTo(401); - } - - - @Test - void testContactIntersection() { - Response response = - resources.getJerseyTest() - .target("/v1/directory/tokens/") - .request() - .header("Authorization", - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, - AuthHelper.VALID_PASSWORD)) - .header(HttpHeaders.X_FORWARDED_FOR, "192.168.1.1, 1.1.1.1") - .put(Entity.entity(Collections.emptyMap(), MediaType.APPLICATION_JSON_TYPE)); - - - assertThat(response.getStatus()).isEqualTo(429); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DirectoryControllerV2Test.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DirectoryControllerV2Test.java deleted file mode 100644 index 85ce0fa14..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DirectoryControllerV2Test.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration; -import org.whispersystems.textsecuregcm.controllers.DirectoryV2Controller; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Pair; - -class DirectoryControllerV2Test { - - @Test - void testAuthToken() { - final ExternalServiceCredentialsGenerator credentialsGenerator = DirectoryV2Controller.credentialsGenerator( - new DirectoryV2ClientConfiguration(new byte[]{0x1}, new byte[]{0x2}), - Clock.fixed(Instant.ofEpochSecond(1633738643L), ZoneId.of("Etc/UTC")) - ); - - final DirectoryV2Controller controller = new DirectoryV2Controller(credentialsGenerator); - - final Account account = mock(Account.class); - final UUID uuid = UUID.fromString("11111111-1111-1111-1111-111111111111"); - when(account.getUuid()).thenReturn(uuid); - - final ExternalServiceCredentials credentials = (ExternalServiceCredentials) controller.getAuthToken( - new AuthenticatedAccount(() -> new Pair<>(account, mock(Device.class)))).getEntity(); - - assertEquals(credentials.username(), "d369bc712e2e0dd36258"); - assertEquals(credentials.password(), "1633738643:4433b0fab41f25f79dd4"); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java deleted file mode 100644 index 3935568ed..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/DonationControllerTest.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.same; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Instant; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation; -import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; -import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.configuration.BadgeConfiguration; -import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; -import org.whispersystems.textsecuregcm.controllers.DonationController; -import org.whispersystems.textsecuregcm.entities.BadgeSvg; -import org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest; -import org.whispersystems.textsecuregcm.storage.AccountBadge; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.TestClock; - -class DonationControllerTest { - - private static final long nowEpochSeconds = 1_500_000_000L; - - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - static BadgesConfiguration getBadgesConfiguration() { - return new BadgesConfiguration( - List.of( - 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"))), - 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"))), - 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"))), - 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")))), - List.of("TEST"), - Map.of(1L, "TEST1", 2L, "TEST2", 3L, "TEST3")); - } - - final Clock clock = TestClock.pinned(Instant.ofEpochSecond(nowEpochSeconds)); - ServerZkReceiptOperations zkReceiptOperations; - RedeemedReceiptsManager redeemedReceiptsManager; - AccountsManager accountsManager; - byte[] receiptSerialBytes; - ReceiptSerial receiptSerial; - byte[] presentation; - DonationController.ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory; - ReceiptCredentialPresentation receiptCredentialPresentation; - ResourceExtension resources; - - @BeforeEach - void beforeEach() throws Throwable { - zkReceiptOperations = mock(ServerZkReceiptOperations.class); - redeemedReceiptsManager = mock(RedeemedReceiptsManager.class); - accountsManager = mock(AccountsManager.class); - AccountsHelper.setupMockUpdate(accountsManager); - receiptSerialBytes = new byte[ReceiptSerial.SIZE]; - SECURE_RANDOM.nextBytes(receiptSerialBytes); - receiptSerial = new ReceiptSerial(receiptSerialBytes); - presentation = new byte[25]; - SECURE_RANDOM.nextBytes(presentation); - receiptCredentialPresentationFactory = mock(DonationController.ReceiptCredentialPresentationFactory.class); - receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class); - - try { - when(receiptCredentialPresentationFactory.build(presentation)).thenReturn(receiptCredentialPresentation); - } catch (InvalidInputException e) { - throw new AssertionError(e); - } - - resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, - getBadgesConfiguration(), receiptCredentialPresentationFactory)) - .build(); - resources.before(); - } - - @AfterEach - void afterEach() throws Throwable { - resources.after(); - } - - @Test - void testRedeemReceipt() { - when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial); - final long receiptLevel = 1L; - when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel); - final long receiptExpiration = nowEpochSeconds + 86400 * 30; - when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration); - when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn( - CompletableFuture.completedFuture(Boolean.TRUE)); - when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID))).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT)); - - RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true); - Response response = resources.getJerseyTest() - .target("/v1/donation/redeem-receipt") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(200); - verify(AuthHelper.VALID_ACCOUNT).addBadge(same(clock), eq(new AccountBadge("TEST1", Instant.ofEpochSecond(receiptExpiration), true))); - verify(AuthHelper.VALID_ACCOUNT).makeBadgePrimaryIfExists(same(clock), eq("TEST1")); - } - - @Test - void testRedeemReceiptAlreadyRedeemedWithDifferentParameters() { - when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial); - final long receiptLevel = 1L; - when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel); - final long receiptExpiration = nowEpochSeconds + 86400 * 30; - when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration); - when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn( - CompletableFuture.completedFuture(Boolean.FALSE)); - - RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true); - Response response = resources.getJerseyTest() - .target("/v1/donation/redeem-receipt") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(400); - assertThat(response.readEntity(String.class)).isEqualTo("receipt serial is already redeemed"); - } - - @Test - void testRedeemReceiptBadCredentialPresentation() throws InvalidInputException { - when(receiptCredentialPresentationFactory.build(any())).thenThrow(new InvalidInputException()); - - final Response response = resources.getJerseyTest() - .target("/v1/donation/redeem-receipt") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .post(Entity.entity(new RedeemReceiptRequest(presentation, true, true), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(400); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/KeysControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/KeysControllerTest.java deleted file mode 100644 index 49604832c..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/KeysControllerTest.java +++ /dev/null @@ -1,633 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.time.Duration; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.UUID; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.OptionalAccess; -import org.whispersystems.textsecuregcm.controllers.KeysController; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.entities.PreKey; -import org.whispersystems.textsecuregcm.entities.PreKeyCount; -import org.whispersystems.textsecuregcm.entities.PreKeyResponse; -import org.whispersystems.textsecuregcm.entities.PreKeyState; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.Keys; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class KeysControllerTest { - - private static final String EXISTS_NUMBER = "+14152222222"; - private static final UUID EXISTS_UUID = UUID.randomUUID(); - private static final UUID EXISTS_PNI = UUID.randomUUID(); - - private static final String NOT_EXISTS_NUMBER = "+14152222220"; - private static final UUID NOT_EXISTS_UUID = UUID.randomUUID(); - - private static final int SAMPLE_REGISTRATION_ID = 999; - private static final int SAMPLE_REGISTRATION_ID2 = 1002; - private static final int SAMPLE_REGISTRATION_ID4 = 1555; - - private static final int SAMPLE_PNI_REGISTRATION_ID = 1717; - - private final PreKey SAMPLE_KEY = new PreKey(1234, "test1"); - private final PreKey SAMPLE_KEY2 = new PreKey(5667, "test3"); - private final PreKey SAMPLE_KEY3 = new PreKey(334, "test5"); - private final PreKey SAMPLE_KEY4 = new PreKey(336, "test6"); - - private final PreKey SAMPLE_KEY_PNI = new PreKey(7777, "test7"); - - private final SignedPreKey SAMPLE_SIGNED_KEY = new SignedPreKey( 1111, "foofoo", "sig11" ); - private final SignedPreKey SAMPLE_SIGNED_KEY2 = new SignedPreKey( 2222, "foobar", "sig22" ); - private final SignedPreKey SAMPLE_SIGNED_KEY3 = new SignedPreKey( 3333, "barfoo", "sig33" ); - private final SignedPreKey SAMPLE_SIGNED_PNI_KEY = new SignedPreKey( 4444, "foofoopni", "sig44" ); - private final SignedPreKey SAMPLE_SIGNED_PNI_KEY2 = new SignedPreKey( 5555, "foobarpni", "sig55" ); - private final SignedPreKey SAMPLE_SIGNED_PNI_KEY3 = new SignedPreKey( 6666, "barfoopni", "sig66" ); - private final SignedPreKey VALID_DEVICE_SIGNED_KEY = new SignedPreKey(89898, "zoofarb", "sigvalid"); - private final SignedPreKey VALID_DEVICE_PNI_SIGNED_KEY = new SignedPreKey(7777, "zoofarber", "sigvalidest"); - - private final static Keys KEYS = mock(Keys.class ); - private final static AccountsManager accounts = mock(AccountsManager.class ); - private final static Account existsAccount = mock(Account.class ); - - private static final RateLimiters rateLimiters = mock(RateLimiters.class); - private static final RateLimiter rateLimiter = mock(RateLimiter.class ); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of( - AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new ServerRejectedExceptionMapper()) - .addResource(new KeysController(rateLimiters, KEYS, accounts)) - .addResource(new RateLimitExceededExceptionMapper()) - .build(); - - private Device sampleDevice; - - @BeforeEach - void setup() { - sampleDevice = mock(Device.class); - final Device sampleDevice2 = mock(Device.class); - final Device sampleDevice3 = mock(Device.class); - final Device sampleDevice4 = mock(Device.class); - - final List allDevices = List.of(sampleDevice, sampleDevice2, sampleDevice3, sampleDevice4); - - AccountsHelper.setupMockUpdate(accounts); - - when(sampleDevice.getRegistrationId()).thenReturn(SAMPLE_REGISTRATION_ID); - when(sampleDevice2.getRegistrationId()).thenReturn(SAMPLE_REGISTRATION_ID2); - when(sampleDevice3.getRegistrationId()).thenReturn(SAMPLE_REGISTRATION_ID2); - when(sampleDevice4.getRegistrationId()).thenReturn(SAMPLE_REGISTRATION_ID4); - when(sampleDevice.getPhoneNumberIdentityRegistrationId()).thenReturn(OptionalInt.of(SAMPLE_PNI_REGISTRATION_ID)); - when(sampleDevice.isEnabled()).thenReturn(true); - when(sampleDevice2.isEnabled()).thenReturn(true); - when(sampleDevice3.isEnabled()).thenReturn(false); - when(sampleDevice4.isEnabled()).thenReturn(true); - when(sampleDevice.getSignedPreKey()).thenReturn(SAMPLE_SIGNED_KEY); - when(sampleDevice2.getSignedPreKey()).thenReturn(SAMPLE_SIGNED_KEY2); - when(sampleDevice3.getSignedPreKey()).thenReturn(SAMPLE_SIGNED_KEY3); - when(sampleDevice4.getSignedPreKey()).thenReturn(null); - when(sampleDevice.getPhoneNumberIdentitySignedPreKey()).thenReturn(SAMPLE_SIGNED_PNI_KEY); - when(sampleDevice2.getPhoneNumberIdentitySignedPreKey()).thenReturn(SAMPLE_SIGNED_PNI_KEY2); - when(sampleDevice3.getPhoneNumberIdentitySignedPreKey()).thenReturn(SAMPLE_SIGNED_PNI_KEY3); - when(sampleDevice4.getPhoneNumberIdentitySignedPreKey()).thenReturn(null); - when(sampleDevice.getId()).thenReturn(1L); - when(sampleDevice2.getId()).thenReturn(2L); - when(sampleDevice3.getId()).thenReturn(3L); - when(sampleDevice4.getId()).thenReturn(4L); - - when(existsAccount.getUuid()).thenReturn(EXISTS_UUID); - when(existsAccount.getPhoneNumberIdentifier()).thenReturn(EXISTS_PNI); - when(existsAccount.getDevice(1L)).thenReturn(Optional.of(sampleDevice)); - when(existsAccount.getDevice(2L)).thenReturn(Optional.of(sampleDevice2)); - when(existsAccount.getDevice(3L)).thenReturn(Optional.of(sampleDevice3)); - when(existsAccount.getDevice(4L)).thenReturn(Optional.of(sampleDevice4)); - when(existsAccount.getDevice(22L)).thenReturn(Optional.empty()); - when(existsAccount.getDevices()).thenReturn(allDevices); - when(existsAccount.isEnabled()).thenReturn(true); - when(existsAccount.getIdentityKey()).thenReturn("existsidentitykey"); - when(existsAccount.getPhoneNumberIdentityKey()).thenReturn("existspniidentitykey"); - when(existsAccount.getNumber()).thenReturn(EXISTS_NUMBER); - when(existsAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of("1337".getBytes())); - - when(accounts.getByE164(EXISTS_NUMBER)).thenReturn(Optional.of(existsAccount)); - when(accounts.getByAccountIdentifier(EXISTS_UUID)).thenReturn(Optional.of(existsAccount)); - when(accounts.getByPhoneNumberIdentifier(EXISTS_PNI)).thenReturn(Optional.of(existsAccount)); - - when(accounts.getByE164(NOT_EXISTS_NUMBER)).thenReturn(Optional.empty()); - when(accounts.getByAccountIdentifier(NOT_EXISTS_UUID)).thenReturn(Optional.empty()); - - when(rateLimiters.getPreKeysLimiter()).thenReturn(rateLimiter); - - when(KEYS.take(EXISTS_UUID, 1)).thenReturn(Optional.of(SAMPLE_KEY)); - when(KEYS.take(EXISTS_PNI, 1)).thenReturn(Optional.of(SAMPLE_KEY_PNI)); - - when(KEYS.getCount(AuthHelper.VALID_UUID, 1)).thenReturn(5); - - when(AuthHelper.VALID_DEVICE.getSignedPreKey()).thenReturn(VALID_DEVICE_SIGNED_KEY); - when(AuthHelper.VALID_DEVICE.getPhoneNumberIdentitySignedPreKey()).thenReturn(VALID_DEVICE_PNI_SIGNED_KEY); - when(AuthHelper.VALID_ACCOUNT.getIdentityKey()).thenReturn(null); - } - - @AfterEach - void teardown() { - reset( - KEYS, - accounts, - existsAccount, - rateLimiters, - rateLimiter - ); - - clearInvocations(AuthHelper.VALID_DEVICE); - } - - @Test - void validKeyStatusTest() { - PreKeyCount result = resources.getJerseyTest() - .target("/v2/keys") - .request() - .header("Authorization", - AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(PreKeyCount.class); - - assertThat(result.getCount()).isEqualTo(4); - - verify(KEYS).getCount(AuthHelper.VALID_UUID, 1); - } - - - @Test - void getSignedPreKeyV2() { - SignedPreKey result = resources.getJerseyTest() - .target("/v2/keys/signed") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(SignedPreKey.class); - - assertThat(result.getSignature()).isEqualTo(VALID_DEVICE_SIGNED_KEY.getSignature()); - assertThat(result.getKeyId()).isEqualTo(VALID_DEVICE_SIGNED_KEY.getKeyId()); - assertThat(result.getPublicKey()).isEqualTo(VALID_DEVICE_SIGNED_KEY.getPublicKey()); - } - - @Test - void getPhoneNumberIdentifierSignedPreKeyV2() { - SignedPreKey result = resources.getJerseyTest() - .target("/v2/keys/signed") - .queryParam("identity", "pni") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(SignedPreKey.class); - - assertThat(result.getSignature()).isEqualTo(VALID_DEVICE_PNI_SIGNED_KEY.getSignature()); - assertThat(result.getKeyId()).isEqualTo(VALID_DEVICE_PNI_SIGNED_KEY.getKeyId()); - assertThat(result.getPublicKey()).isEqualTo(VALID_DEVICE_PNI_SIGNED_KEY.getPublicKey()); - } - - @Test - void putSignedPreKeyV2() { - SignedPreKey test = new SignedPreKey(9998, "fooozzz", "baaarzzz"); - Response response = resources.getJerseyTest() - .target("/v2/keys/signed") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(test, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(204); - - verify(AuthHelper.VALID_DEVICE).setSignedPreKey(eq(test)); - verify(AuthHelper.VALID_DEVICE, never()).setPhoneNumberIdentitySignedPreKey(any()); - verify(accounts).updateDevice(eq(AuthHelper.VALID_ACCOUNT), anyLong(), any()); - } - - @Test - void putPhoneNumberIdentitySignedPreKeyV2() { - final SignedPreKey replacementKey = new SignedPreKey(9998, "fooozzz", "baaarzzz"); - - Response response = resources.getJerseyTest() - .target("/v2/keys/signed") - .queryParam("identity", "pni") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(replacementKey, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(204); - - verify(AuthHelper.VALID_DEVICE).setPhoneNumberIdentitySignedPreKey(eq(replacementKey)); - verify(AuthHelper.VALID_DEVICE, never()).setSignedPreKey(any()); - verify(accounts).updateDevice(eq(AuthHelper.VALID_ACCOUNT), anyLong(), any()); - } - - @Test - void disabledPutSignedPreKeyV2() { - SignedPreKey test = new SignedPreKey(9999, "fooozzz", "baaarzzz"); - Response response = resources.getJerseyTest() - .target("/v2/keys/signed") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .put(Entity.entity(test, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void validSingleRequestTestV2() { - PreKeyResponse result = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(PreKeyResponse.class); - - assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey()); - assertThat(result.getDevicesCount()).isEqualTo(1); - assertThat(result.getDevice(1).getPreKey().getKeyId()).isEqualTo(SAMPLE_KEY.getKeyId()); - assertThat(result.getDevice(1).getPreKey().getPublicKey()).isEqualTo(SAMPLE_KEY.getPublicKey()); - assertThat(result.getDevice(1).getRegistrationId()).isEqualTo(SAMPLE_REGISTRATION_ID); - assertThat(result.getDevice(1).getSignedPreKey()).isEqualTo(existsAccount.getDevice(1).get().getSignedPreKey()); - - verify(KEYS).take(EXISTS_UUID, 1); - verifyNoMoreInteractions(KEYS); - } - - @Test - void validSingleRequestByPhoneNumberIdentifierTestV2() { - PreKeyResponse result = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_PNI)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(PreKeyResponse.class); - - assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getPhoneNumberIdentityKey()); - assertThat(result.getDevicesCount()).isEqualTo(1); - assertThat(result.getDevice(1).getPreKey().getKeyId()).isEqualTo(SAMPLE_KEY_PNI.getKeyId()); - assertThat(result.getDevice(1).getPreKey().getPublicKey()).isEqualTo(SAMPLE_KEY_PNI.getPublicKey()); - assertThat(result.getDevice(1).getRegistrationId()).isEqualTo(SAMPLE_PNI_REGISTRATION_ID); - assertThat(result.getDevice(1).getSignedPreKey()).isEqualTo(existsAccount.getDevice(1).get().getPhoneNumberIdentitySignedPreKey()); - - verify(KEYS).take(EXISTS_PNI, 1); - verifyNoMoreInteractions(KEYS); - } - - @Test - void validSingleRequestByPhoneNumberIdentifierNoPniRegistrationIdTestV2() { - when(sampleDevice.getPhoneNumberIdentityRegistrationId()).thenReturn(OptionalInt.empty()); - - PreKeyResponse result = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_PNI)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(PreKeyResponse.class); - - assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getPhoneNumberIdentityKey()); - assertThat(result.getDevicesCount()).isEqualTo(1); - assertThat(result.getDevice(1).getPreKey().getKeyId()).isEqualTo(SAMPLE_KEY_PNI.getKeyId()); - assertThat(result.getDevice(1).getPreKey().getPublicKey()).isEqualTo(SAMPLE_KEY_PNI.getPublicKey()); - assertThat(result.getDevice(1).getRegistrationId()).isEqualTo(SAMPLE_REGISTRATION_ID); - assertThat(result.getDevice(1).getSignedPreKey()).isEqualTo(existsAccount.getDevice(1).get().getPhoneNumberIdentitySignedPreKey()); - - verify(KEYS).take(EXISTS_PNI, 1); - verifyNoMoreInteractions(KEYS); - } - - @Test - void testGetKeysRateLimited() throws RateLimitExceededException { - Duration retryAfter = Duration.ofSeconds(31); - doThrow(new RateLimitExceededException(retryAfter, true)).when(rateLimiter).validate(anyString()); - - Response result = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/*", EXISTS_PNI)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(result.getStatus()).isEqualTo(413); - assertThat(result.getHeaderString("Retry-After")).isEqualTo(String.valueOf(retryAfter.toSeconds())); - } - - @Test - void testUnidentifiedRequest() { - PreKeyResponse result = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_UUID)) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) - .get(PreKeyResponse.class); - - assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey()); - assertThat(result.getDevicesCount()).isEqualTo(1); - assertThat(result.getDevice(1).getPreKey().getKeyId()).isEqualTo(SAMPLE_KEY.getKeyId()); - assertThat(result.getDevice(1).getPreKey().getPublicKey()).isEqualTo(SAMPLE_KEY.getPublicKey()); - assertThat(result.getDevice(1).getSignedPreKey()).isEqualTo(existsAccount.getDevice(1).get().getSignedPreKey()); - - verify(KEYS).take(EXISTS_UUID, 1); - verifyNoMoreInteractions(KEYS); - } - - @Test - void testNoDevices() { - - when(existsAccount.getDevices()).thenReturn(Collections.emptyList()); - - Response result = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/*", EXISTS_UUID)) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("1337".getBytes())) - .get(); - - assertThat(result).isNotNull(); - assertThat(result.getStatus()).isEqualTo(404); - } - - @Test - void testUnauthorizedUnidentifiedRequest() { - Response response = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_UUID)) - .request() - .header(OptionalAccess.UNIDENTIFIED, AuthHelper.getUnidentifiedAccessHeader("9999".getBytes())) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - verifyNoMoreInteractions(KEYS); - } - - @Test - void testMalformedUnidentifiedRequest() { - Response response = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_UUID)) - .request() - .header(OptionalAccess.UNIDENTIFIED, "$$$$$$$$$") - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - verifyNoMoreInteractions(KEYS); - } - - - @Test - void validMultiRequestTestV2() { - when(KEYS.take(EXISTS_UUID, 1)).thenReturn(Optional.of(SAMPLE_KEY)); - when(KEYS.take(EXISTS_UUID, 2)).thenReturn(Optional.of(SAMPLE_KEY2)); - when(KEYS.take(EXISTS_UUID, 3)).thenReturn(Optional.of(SAMPLE_KEY3)); - when(KEYS.take(EXISTS_UUID, 4)).thenReturn(Optional.of(SAMPLE_KEY4)); - - PreKeyResponse results = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/*", EXISTS_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(PreKeyResponse.class); - - assertThat(results.getDevicesCount()).isEqualTo(3); - assertThat(results.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey()); - - PreKey signedPreKey = results.getDevice(1).getSignedPreKey(); - PreKey preKey = results.getDevice(1).getPreKey(); - long registrationId = results.getDevice(1).getRegistrationId(); - long deviceId = results.getDevice(1).getDeviceId(); - - assertThat(preKey.getKeyId()).isEqualTo(SAMPLE_KEY.getKeyId()); - assertThat(preKey.getPublicKey()).isEqualTo(SAMPLE_KEY.getPublicKey()); - assertThat(registrationId).isEqualTo(SAMPLE_REGISTRATION_ID); - assertThat(signedPreKey.getKeyId()).isEqualTo(SAMPLE_SIGNED_KEY.getKeyId()); - assertThat(signedPreKey.getPublicKey()).isEqualTo(SAMPLE_SIGNED_KEY.getPublicKey()); - assertThat(deviceId).isEqualTo(1); - - signedPreKey = results.getDevice(2).getSignedPreKey(); - preKey = results.getDevice(2).getPreKey(); - registrationId = results.getDevice(2).getRegistrationId(); - deviceId = results.getDevice(2).getDeviceId(); - - assertThat(preKey.getKeyId()).isEqualTo(SAMPLE_KEY2.getKeyId()); - assertThat(preKey.getPublicKey()).isEqualTo(SAMPLE_KEY2.getPublicKey()); - assertThat(registrationId).isEqualTo(SAMPLE_REGISTRATION_ID2); - assertThat(signedPreKey.getKeyId()).isEqualTo(SAMPLE_SIGNED_KEY2.getKeyId()); - assertThat(signedPreKey.getPublicKey()).isEqualTo(SAMPLE_SIGNED_KEY2.getPublicKey()); - assertThat(deviceId).isEqualTo(2); - - signedPreKey = results.getDevice(4).getSignedPreKey(); - preKey = results.getDevice(4).getPreKey(); - registrationId = results.getDevice(4).getRegistrationId(); - deviceId = results.getDevice(4).getDeviceId(); - - assertThat(preKey.getKeyId()).isEqualTo(SAMPLE_KEY4.getKeyId()); - assertThat(preKey.getPublicKey()).isEqualTo(SAMPLE_KEY4.getPublicKey()); - assertThat(registrationId).isEqualTo(SAMPLE_REGISTRATION_ID4); - assertThat(signedPreKey).isNull(); - assertThat(deviceId).isEqualTo(4); - - verify(KEYS).take(EXISTS_UUID, 1); - verify(KEYS).take(EXISTS_UUID, 2); - verify(KEYS).take(EXISTS_UUID, 3); - verify(KEYS).take(EXISTS_UUID, 4); - verifyNoMoreInteractions(KEYS); - } - - - @Test - void invalidRequestTestV2() { - Response response = resources.getJerseyTest() - .target(String.format("/v2/keys/%s", NOT_EXISTS_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(404); - } - - @Test - void anotherInvalidRequestTestV2() { - Response response = resources.getJerseyTest() - .target(String.format("/v2/keys/%s/22", EXISTS_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(404); - } - - @Test - void unauthorizedRequestTestV2() { - Response response = - resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_UUID)) - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) - .get(); - - assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(401); - - response = - resources.getJerseyTest() - .target(String.format("/v2/keys/%s/1", EXISTS_UUID)) - .request() - .get(); - - assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(401); - } - - @Test - void putKeysTestV2() { - final PreKey preKey = new PreKey(31337, "foobar"); - final SignedPreKey signedPreKey = new SignedPreKey(31338, "foobaz", "myvalidsig"); - final String identityKey = "barbar"; - - List preKeys = new LinkedList() {{ - add(preKey); - }}; - - PreKeyState preKeyState = new PreKeyState(identityKey, signedPreKey, preKeys); - - Response response = - resources.getJerseyTest() - .target("/v2/keys") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(204); - - ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); - verify(KEYS).store(eq(AuthHelper.VALID_UUID), eq(1L), listCaptor.capture()); - - List capturedList = listCaptor.getValue(); - assertThat(capturedList.size()).isEqualTo(1); - assertThat(capturedList.get(0).getKeyId()).isEqualTo(31337); - assertThat(capturedList.get(0).getPublicKey()).isEqualTo("foobar"); - - verify(AuthHelper.VALID_ACCOUNT).setIdentityKey(eq("barbar")); - verify(AuthHelper.VALID_DEVICE).setSignedPreKey(eq(signedPreKey)); - verify(accounts).update(eq(AuthHelper.VALID_ACCOUNT), any()); - } - - @Test - void putKeysByPhoneNumberIdentifierTestV2() { - final SignedPreKey signedPreKey = new SignedPreKey(31338, "foobaz", "myvalidsig"); - final String identityKey = "barbar"; - - List preKeys = List.of(new PreKey(31337, "foobar")); - - PreKeyState preKeyState = new PreKeyState(identityKey, signedPreKey, preKeys); - - Response response = - resources.getJerseyTest() - .target("/v2/keys") - .queryParam("identity", "pni") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(204); - - ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); - verify(KEYS).store(eq(AuthHelper.VALID_PNI), eq(1L), listCaptor.capture()); - - List capturedList = listCaptor.getValue(); - assertThat(capturedList.size()).isEqualTo(1); - assertThat(capturedList.get(0).getKeyId()).isEqualTo(31337); - assertThat(capturedList.get(0).getPublicKey()).isEqualTo("foobar"); - - verify(AuthHelper.VALID_ACCOUNT).setPhoneNumberIdentityKey(eq("barbar")); - verify(AuthHelper.VALID_DEVICE).setPhoneNumberIdentitySignedPreKey(eq(signedPreKey)); - verify(accounts).update(eq(AuthHelper.VALID_ACCOUNT), any()); - } - - @Test - void disabledPutKeysTestV2() { - final PreKey preKey = new PreKey(31337, "foobar"); - final SignedPreKey signedPreKey = new SignedPreKey(31338, "foobaz", "myvalidsig"); - final String identityKey = "barbar"; - - List preKeys = new LinkedList() {{ - add(preKey); - }}; - - PreKeyState preKeyState = new PreKeyState(identityKey, signedPreKey, preKeys); - - Response response = - resources.getJerseyTest() - .target("/v2/keys") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(204); - - ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); - verify(KEYS).store(eq(AuthHelper.DISABLED_UUID), eq(1L), listCaptor.capture()); - - List capturedList = listCaptor.getValue(); - assertThat(capturedList.size()).isEqualTo(1); - assertThat(capturedList.get(0).getKeyId()).isEqualTo(31337); - assertThat(capturedList.get(0).getPublicKey()).isEqualTo("foobar"); - - verify(AuthHelper.DISABLED_ACCOUNT).setIdentityKey(eq("barbar")); - verify(AuthHelper.DISABLED_DEVICE).setSignedPreKey(eq(signedPreKey)); - verify(accounts).update(eq(AuthHelper.DISABLED_ACCOUNT), any()); - } - - @Test - void putIdentityKeyNonPrimary() { - final PreKey preKey = new PreKey(31337, "foobar"); - final SignedPreKey signedPreKey = new SignedPreKey(31338, "foobaz", "myvalidsig"); - final String identityKey = "barbar"; - - List preKeys = List.of(preKey); - - PreKeyState preKeyState = new PreKeyState(identityKey, signedPreKey, preKeys); - - Response response = - resources.getJerseyTest() - .target("/v2/keys") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, 2L, AuthHelper.VALID_PASSWORD_3_LINKED)) - .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(403); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/PaymentsControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/PaymentsControllerTest.java deleted file mode 100644 index 45a6a3cf0..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/PaymentsControllerTest.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.math.BigDecimal; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.controllers.PaymentsController; -import org.whispersystems.textsecuregcm.currency.CurrencyConversionManager; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity; -import org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class PaymentsControllerTest { - - private static final ExternalServiceCredentialsGenerator paymentsCredentialsGenerator = mock(ExternalServiceCredentialsGenerator.class); - private static final CurrencyConversionManager currencyManager = mock(CurrencyConversionManager.class); - - private final ExternalServiceCredentials validCredentials = new ExternalServiceCredentials("username", "password"); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new PaymentsController(currencyManager, paymentsCredentialsGenerator)) - .build(); - - - @BeforeEach - void setup() { - when(paymentsCredentialsGenerator.generateForUuid(eq(AuthHelper.VALID_UUID))).thenReturn(validCredentials); - when(currencyManager.getCurrencyConversions()).thenReturn(Optional.of( - new CurrencyConversionEntityList(List.of( - new CurrencyConversionEntity("FOO", Map.of( - "USD", new BigDecimal("2.35"), - "EUR", new BigDecimal("1.89") - )), - new CurrencyConversionEntity("BAR", Map.of( - "USD", new BigDecimal("1.50"), - "EUR", new BigDecimal("0.98") - )) - ), System.currentTimeMillis()))); - } - - @Test - void testGetAuthToken() { - ExternalServiceCredentials token = - resources.getJerseyTest() - .target("/v1/payments/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(ExternalServiceCredentials.class); - - assertThat(token.username()).isEqualTo(validCredentials.username()); - assertThat(token.password()).isEqualTo(validCredentials.password()); - } - - @Test - void testInvalidAuthGetAuthToken() { - Response response = - resources.getJerseyTest() - .target("/v1/payments/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.INVALID_UUID, AuthHelper.INVALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testDisabledGetAuthToken() { - Response response = - resources.getJerseyTest() - .target("/v1/payments/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.DISABLED_UUID, AuthHelper.DISABLED_PASSWORD)) - .get(); - assertThat(response.getStatus()).isEqualTo(401); - } - - @Test - void testGetCurrencyConversions() { - CurrencyConversionEntityList conversions = - resources.getJerseyTest() - .target("/v1/payments/conversions") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(CurrencyConversionEntityList.class); - - - assertThat(conversions.getCurrencies().size()).isEqualTo(2); - assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo("FOO"); - assertThat(conversions.getCurrencies().get(0).getConversions().get("USD")).isEqualTo(new BigDecimal("2.35")); - } - - @Test - void testGetCurrencyConversions_Json() { - String json = - resources.getJerseyTest() - .target("/v1/payments/conversions") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(String.class); - - // the currency serialization might occur in either order - assertThat(json).containsPattern("\\{(\"EUR\":1.89,\"USD\":2.35|\"USD\":2.35,\"EUR\":1.89)}"); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java deleted file mode 100644 index 2956b5300..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java +++ /dev/null @@ -1,393 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Set; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.signal.event.NoOpAdminEventLogger; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.controllers.RemoteConfigController; -import org.whispersystems.textsecuregcm.entities.UserRemoteConfig; -import org.whispersystems.textsecuregcm.entities.UserRemoteConfigList; -import org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper; -import org.whispersystems.textsecuregcm.storage.RemoteConfig; -import org.whispersystems.textsecuregcm.storage.RemoteConfigsManager; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class RemoteConfigControllerTest { - - private static final RemoteConfigsManager remoteConfigsManager = mock(RemoteConfigsManager.class); - private static final List remoteConfigsAuth = List.of("foo", "bar"); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addProvider(new DeviceLimitExceededExceptionMapper()) - .addResource(new RemoteConfigController(remoteConfigsManager, new NoOpAdminEventLogger(), remoteConfigsAuth, Map.of("maxGroupSize", "42"))) - .build(); - - - @BeforeEach - void setup() { - when(remoteConfigsManager.getAll()).thenReturn(new LinkedList<>() {{ - add(new RemoteConfig("android.stickers", 25, Set.of(AuthHelper.DISABLED_UUID, AuthHelper.INVALID_UUID), null, null, null)); - add(new RemoteConfig("ios.stickers", 50, Set.of(), null, null, null)); - add(new RemoteConfig("always.true", 100, Set.of(), null, null, null)); - add(new RemoteConfig("only.special", 0, Set.of(AuthHelper.VALID_UUID), null, null, null)); - add(new RemoteConfig("value.always.true", 100, Set.of(), "foo", "bar", null)); - add(new RemoteConfig("value.only.special", 0, Set.of(AuthHelper.VALID_UUID), "abc", "xyz", null)); - add(new RemoteConfig("value.always.false", 0, Set.of(), "red", "green", null)); - add(new RemoteConfig("linked.config.0", 50, Set.of(), null, null, null)); - add(new RemoteConfig("linked.config.1", 50, Set.of(), null, null, "linked.config.0")); - add(new RemoteConfig("unlinked.config", 50, Set.of(), null, null, null)); - }}); - } - - @AfterEach - void teardown() { - reset(remoteConfigsManager); - } - - @Test - void testRetrieveConfig() { - UserRemoteConfigList configuration = resources.getJerseyTest() - .target("/v1/config/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(UserRemoteConfigList.class); - - verify(remoteConfigsManager, times(1)).getAll(); - - assertThat(configuration.getConfig()).hasSize(11); - assertThat(configuration.getConfig().get(0).getName()).isEqualTo("android.stickers"); - assertThat(configuration.getConfig().get(1).getName()).isEqualTo("ios.stickers"); - assertThat(configuration.getConfig().get(2).getName()).isEqualTo("always.true"); - assertThat(configuration.getConfig().get(2).isEnabled()).isEqualTo(true); - assertThat(configuration.getConfig().get(2).getValue()).isNull(); - assertThat(configuration.getConfig().get(3).getName()).isEqualTo("only.special"); - assertThat(configuration.getConfig().get(3).isEnabled()).isEqualTo(true); - assertThat(configuration.getConfig().get(2).getValue()).isNull(); - assertThat(configuration.getConfig().get(4).getName()).isEqualTo("value.always.true"); - assertThat(configuration.getConfig().get(4).isEnabled()).isEqualTo(true); - assertThat(configuration.getConfig().get(4).getValue()).isEqualTo("bar"); - assertThat(configuration.getConfig().get(5).getName()).isEqualTo("value.only.special"); - assertThat(configuration.getConfig().get(5).isEnabled()).isEqualTo(true); - assertThat(configuration.getConfig().get(5).getValue()).isEqualTo("xyz"); - assertThat(configuration.getConfig().get(6).getName()).isEqualTo("value.always.false"); - assertThat(configuration.getConfig().get(6).isEnabled()).isEqualTo(false); - assertThat(configuration.getConfig().get(6).getValue()).isEqualTo("red"); - assertThat(configuration.getConfig().get(7).getName()).isEqualTo("linked.config.0"); - assertThat(configuration.getConfig().get(8).getName()).isEqualTo("linked.config.1"); - assertThat(configuration.getConfig().get(9).getName()).isEqualTo("unlinked.config"); - assertThat(configuration.getConfig().get(10).getName()).isEqualTo("global.maxGroupSize"); - } - - @Test - void testRetrieveConfigNotSpecial() { - UserRemoteConfigList configuration = resources.getJerseyTest() - .target("/v1/config/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO)) - .get(UserRemoteConfigList.class); - - verify(remoteConfigsManager, times(1)).getAll(); - - assertThat(configuration.getConfig()).hasSize(11); - assertThat(configuration.getConfig().get(0).getName()).isEqualTo("android.stickers"); - assertThat(configuration.getConfig().get(1).getName()).isEqualTo("ios.stickers"); - assertThat(configuration.getConfig().get(2).getName()).isEqualTo("always.true"); - assertThat(configuration.getConfig().get(2).isEnabled()).isEqualTo(true); - assertThat(configuration.getConfig().get(2).getValue()).isNull(); - assertThat(configuration.getConfig().get(3).getName()).isEqualTo("only.special"); - assertThat(configuration.getConfig().get(3).isEnabled()).isEqualTo(false); - assertThat(configuration.getConfig().get(2).getValue()).isNull(); - assertThat(configuration.getConfig().get(4).getName()).isEqualTo("value.always.true"); - assertThat(configuration.getConfig().get(4).isEnabled()).isEqualTo(true); - assertThat(configuration.getConfig().get(4).getValue()).isEqualTo("bar"); - assertThat(configuration.getConfig().get(5).getName()).isEqualTo("value.only.special"); - assertThat(configuration.getConfig().get(5).isEnabled()).isEqualTo(false); - assertThat(configuration.getConfig().get(5).getValue()).isEqualTo("abc"); - assertThat(configuration.getConfig().get(6).getName()).isEqualTo("value.always.false"); - assertThat(configuration.getConfig().get(6).isEnabled()).isEqualTo(false); - assertThat(configuration.getConfig().get(6).getValue()).isEqualTo("red"); - assertThat(configuration.getConfig().get(7).getName()).isEqualTo("linked.config.0"); - assertThat(configuration.getConfig().get(8).getName()).isEqualTo("linked.config.1"); - assertThat(configuration.getConfig().get(9).getName()).isEqualTo("unlinked.config"); - assertThat(configuration.getConfig().get(10).getName()).isEqualTo("global.maxGroupSize"); - } - - @Test - void testHashKeyLinkedConfigs() { - boolean allUnlinkedConfigsMatched = true; - for (AuthHelper.TestAccount testAccount : AuthHelper.TEST_ACCOUNTS) { - UserRemoteConfigList configuration = resources.getJerseyTest().target("/v1/config/").request().header("Authorization", testAccount.getAuthHeader()).get(UserRemoteConfigList.class); - assertThat(configuration.getConfig()).hasSize(11); - - final UserRemoteConfig linkedConfig0 = configuration.getConfig().get(7); - assertThat(linkedConfig0.getName()).isEqualTo("linked.config.0"); - - final UserRemoteConfig linkedConfig1 = configuration.getConfig().get(8); - assertThat(linkedConfig1.getName()).isEqualTo("linked.config.1"); - - final UserRemoteConfig unlinkedConfig = configuration.getConfig().get(9); - assertThat(unlinkedConfig.getName()).isEqualTo("unlinked.config"); - - assertThat(linkedConfig0.isEnabled() == linkedConfig1.isEnabled()).isTrue(); - allUnlinkedConfigsMatched &= (linkedConfig0.isEnabled() == unlinkedConfig.isEnabled()); - } - assertThat(allUnlinkedConfigsMatched).isFalse(); - } - - - @Test - void testRetrieveConfigUnauthorized() { - Response response = resources.getJerseyTest() - .target("/v1/config/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - - verifyNoMoreInteractions(remoteConfigsManager); - } - - - @Test - void testSetConfig() { - Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(204); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RemoteConfig.class); - - verify(remoteConfigsManager, times(1)).set(captor.capture()); - - assertThat(captor.getValue().getName()).isEqualTo("android.stickers"); - assertThat(captor.getValue().getPercentage()).isEqualTo(88); - assertThat(captor.getValue().getUuids()).isEmpty(); - } - - @Test - void testSetConfigValued() { - Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("value.sometimes", 50, Set.of(), "a", "b", null), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(204); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RemoteConfig.class); - - verify(remoteConfigsManager, times(1)).set(captor.capture()); - - assertThat(captor.getValue().getName()).isEqualTo("value.sometimes"); - assertThat(captor.getValue().getPercentage()).isEqualTo(50); - assertThat(captor.getValue().getUuids()).isEmpty(); - } - - @Test - void testSetConfigWithHashKey() { - Response response1 = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("linked.config.0", 50, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); - assertThat(response1.getStatus()).isEqualTo(204); - - Response response2 = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("linked.config.1", 50, Set.of(), "FALSE", "TRUE", "linked.config.0"), MediaType.APPLICATION_JSON_TYPE)); - assertThat(response2.getStatus()).isEqualTo(204); - - ArgumentCaptor captor = ArgumentCaptor.forClass(RemoteConfig.class); - - verify(remoteConfigsManager, times(2)).set(captor.capture()); - assertThat(captor.getAllValues()).hasSize(2); - - final RemoteConfig capture1 = captor.getAllValues().get(0); - assertThat(capture1).isNotNull(); - assertThat(capture1.getName()).isEqualTo("linked.config.0"); - assertThat(capture1.getPercentage()).isEqualTo(50); - assertThat(capture1.getUuids()).isEmpty(); - assertThat(capture1.getHashKey()).isNull(); - - final RemoteConfig capture2 = captor.getAllValues().get(1); - assertThat(capture2).isNotNull(); - assertThat(capture2.getName()).isEqualTo("linked.config.1"); - assertThat(capture2.getPercentage()).isEqualTo(50); - assertThat(capture2.getUuids()).isEmpty(); - assertThat(capture2.getHashKey()).isEqualTo("linked.config.0"); - } - - @Test - void testSetConfigUnauthorized() { - Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "baz") - .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(401); - - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testSetConfigMissingUnauthorized() { - Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(401); - - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testSetConfigBadName() { - Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("android-stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(422); - - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testSetConfigEmptyName() { - Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); - - assertThat(response.getStatus()).isEqualTo(422); - - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testSetGlobalConfig() { - Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("global.maxGroupSize", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); - assertThat(response.getStatus()).isEqualTo(403); - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testDelete() { - Response response = resources.getJerseyTest() - .target("/v1/config/android.stickers") - .request() - .header("Config-Token", "foo") - .delete(); - - assertThat(response.getStatus()).isEqualTo(204); - - verify(remoteConfigsManager, times(1)).delete("android.stickers"); - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testDeleteUnauthorized() { - Response response = resources.getJerseyTest() - .target("/v1/config/android.stickers") - .request() - .header("Config-Token", "baz") - .delete(); - - assertThat(response.getStatus()).isEqualTo(401); - - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testDeleteGlobalConfig() { - Response response = resources.getJerseyTest() - .target("/v1/config/global.maxGroupSize") - .request() - .header("Config-Token", "foo") - .delete(); - assertThat(response.getStatus()).isEqualTo(403); - verifyNoMoreInteractions(remoteConfigsManager); - } - - @Test - void testMath() throws NoSuchAlgorithmException { - List remoteConfigList = remoteConfigsManager.getAll(); - Map enabledMap = new HashMap<>(); - MessageDigest digest = MessageDigest.getInstance("SHA1"); - int iterations = 100000; - Random random = new Random(9424242L); // the seed value doesn't matter so much as it's constant to make the test not flaky - - for (int i=0;i())) { - count++; - } - - enabledMap.put(config.getName(), count); - } - } - - for (RemoteConfig config : remoteConfigList) { - double targetNumber = iterations * (config.getPercentage() / 100.0); - double variance = targetNumber * 0.01; - - assertThat(enabledMap.get(config.getName())).isBetween((int)(targetNumber - variance), (int)(targetNumber + variance)); - } - - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/SecureStorageControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/SecureStorageControllerTest.java deleted file mode 100644 index a4883c82d..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/SecureStorageControllerTest.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; -import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; -import org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration; -import org.whispersystems.textsecuregcm.controllers.SecureStorageController; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.MockUtils; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class SecureStorageControllerTest { - - private static final SecureStorageServiceConfiguration STORAGE_CFG = MockUtils.buildMock( - SecureStorageServiceConfiguration.class, - cfg -> when(cfg.decodeUserAuthenticationTokenSharedSecret()).thenReturn(new byte[32])); - - private static final ExternalServiceCredentialsGenerator STORAGE_CREDENTIAL_GENERATOR = SecureStorageController - .credentialsGenerator(STORAGE_CFG); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new SecureStorageController(STORAGE_CREDENTIAL_GENERATOR)) - .build(); - - - @Test - void testGetCredentials() throws Exception { - ExternalServiceCredentials credentials = resources.getJerseyTest() - .target("/v1/storage/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(ExternalServiceCredentials.class); - - assertThat(credentials.password()).isNotEmpty(); - assertThat(credentials.username()).isNotEmpty(); - } - - @Test - void testGetCredentialsBadAuth() throws Exception { - Response response = resources.getJerseyTest() - .target("/v1/storage/auth") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.INVALID_UUID, AuthHelper.INVALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(401); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/StickerControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/StickerControllerTest.java deleted file mode 100644 index 021d55913..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/StickerControllerTest.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.controllers; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableSet; -import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.util.Base64; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.controllers.StickerController; -import org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -@ExtendWith(DropwizardExtensionsSupport.class) -class StickerControllerTest { - - private static final RateLimiter rateLimiter = mock(RateLimiter.class ); - private static final RateLimiters rateLimiters = mock(RateLimiters.class); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(AuthHelper.getAuthFilter()) - .addProvider(new PolymorphicAuthValueFactoryProvider.Binder<>( - ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) - .setMapper(SystemMapper.getMapper()) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new StickerController(rateLimiters, "foo", "bar", "us-east-1", "mybucket")) - .build(); - - @BeforeEach - void setup() { - when(rateLimiters.getStickerPackLimiter()).thenReturn(rateLimiter); - } - - @Test - void testCreatePack() throws RateLimitExceededException { - StickerPackFormUploadAttributes attributes = resources.getJerseyTest() - .target("/v1/sticker/pack/form/10") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(StickerPackFormUploadAttributes.class); - - assertThat(attributes.getPackId()).isNotNull(); - assertThat(attributes.getPackId().length()).isEqualTo(32); - - assertThat(attributes.getManifest()).isNotNull(); - assertThat(attributes.getManifest().getKey()).isEqualTo("stickers/" + attributes.getPackId() + "/manifest.proto"); - assertThat(attributes.getManifest().getAcl()).isEqualTo("private"); - assertThat(attributes.getManifest().getPolicy()).isNotEmpty(); - assertThat(new String(Base64.getDecoder().decode(attributes.getManifest().getPolicy()))).contains("[\"content-length-range\", 1, 10240]"); - assertThat(attributes.getManifest().getSignature()).isNotEmpty(); - assertThat(attributes.getManifest().getAlgorithm()).isEqualTo("AWS4-HMAC-SHA256"); - assertThat(attributes.getManifest().getCredential()).isNotEmpty(); - assertThat(attributes.getManifest().getId()).isEqualTo(-1); - - assertThat(attributes.getStickers().size()).isEqualTo(10); - - for (int i=0;i<10;i++) { - assertThat(attributes.getStickers().get(i).getId()).isEqualTo(i); - assertThat(attributes.getStickers().get(i).getKey()).isEqualTo("stickers/" + attributes.getPackId() + "/full/" + i); - assertThat(attributes.getStickers().get(i).getAcl()).isEqualTo("private"); - assertThat(attributes.getStickers().get(i).getPolicy()).isNotEmpty(); - assertThat(new String(Base64.getDecoder().decode(attributes.getStickers().get(i).getPolicy()))).contains("[\"content-length-range\", 1, 308224]"); - assertThat(attributes.getStickers().get(i).getSignature()).isNotEmpty(); - assertThat(attributes.getStickers().get(i).getAlgorithm()).isEqualTo("AWS4-HMAC-SHA256"); - assertThat(attributes.getStickers().get(i).getCredential()).isNotEmpty(); - } - - verify(rateLimiters, times(1)).getStickerPackLimiter(); - verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID); - } - - @Test - void testCreateTooLargePack() { - Response response = resources.getJerseyTest() - .target("/v1/sticker/pack/form/202") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) - .get(); - - assertThat(response.getStatus()).isEqualTo(400); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/entities/PreKeyTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/entities/PreKeyTest.java deleted file mode 100644 index 74fa9ae87..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/entities/PreKeyTest.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.entities; - -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.asJson; -import static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture; - -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.entities.PreKey; - -class PreKeyTest { - - @Test - void serializeToJSONV2() throws Exception { - PreKey preKey = new PreKey(1234, "test"); - - assertThat("PreKeyV2 Serialization works", - asJson(preKey), - is(equalTo(jsonFixture("fixtures/prekey_v2.json")))); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/http/FaultTolerantHttpClientTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/http/FaultTolerantHttpClientTest.java deleted file mode 100644 index a3d963b9b..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/http/FaultTolerantHttpClientTest.java +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.http; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; - -import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import io.github.resilience4j.circuitbreaker.CallNotPermittedException; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.concurrent.CompletionException; -import java.util.concurrent.Executors; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; -import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient; - -class FaultTolerantHttpClientTest { - - @RegisterExtension - private final WireMockExtension wireMock = WireMockExtension.newInstance() - .options(wireMockConfig().dynamicPort().dynamicHttpsPort()) - .build(); - - @Test - void testSimpleGet() { - wireMock.stubFor(get(urlEqualTo("/ping")) - .willReturn(aResponse() - .withHeader("Content-Type", "text/plain") - .withBody("Pong!"))); - - - FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder() - .withCircuitBreaker(new CircuitBreakerConfiguration()) - .withRetry(new RetryConfiguration()) - .withExecutor(Executors.newSingleThreadExecutor()) - .withName("test") - .withVersion(HttpClient.Version.HTTP_2) - .build(); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + wireMock.getPort() + "/ping")) - .GET() - .build(); - - HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - - assertThat(response.statusCode()).isEqualTo(200); - assertThat(response.body()).isEqualTo("Pong!"); - - wireMock.verify(1, getRequestedFor(urlEqualTo("/ping"))); - } - - @Test - void testRetryGet() { - wireMock.stubFor(get(urlEqualTo("/failure")) - .willReturn(aResponse() - .withStatus(500) - .withHeader("Content-Type", "text/plain") - .withBody("Pong!"))); - - FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder() - .withCircuitBreaker(new CircuitBreakerConfiguration()) - .withRetry(new RetryConfiguration()) - .withExecutor(Executors.newSingleThreadExecutor()) - .withName("test") - .withVersion(HttpClient.Version.HTTP_2) - .build(); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + wireMock.getPort() + "/failure")) - .GET() - .build(); - - HttpResponse response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - - assertThat(response.statusCode()).isEqualTo(500); - assertThat(response.body()).isEqualTo("Pong!"); - - wireMock.verify(3, getRequestedFor(urlEqualTo("/failure"))); - } - - @Test - void testNetworkFailureCircuitBreaker() throws InterruptedException { - CircuitBreakerConfiguration circuitBreakerConfiguration = new CircuitBreakerConfiguration(); - circuitBreakerConfiguration.setSlidingWindowSize(2); - circuitBreakerConfiguration.setSlidingWindowMinimumNumberOfCalls(2); - circuitBreakerConfiguration.setPermittedNumberOfCallsInHalfOpenState(1); - circuitBreakerConfiguration.setFailureRateThreshold(50); - circuitBreakerConfiguration.setWaitDurationInOpenStateInSeconds(1); - - FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder() - .withCircuitBreaker(circuitBreakerConfiguration) - .withRetry(new RetryConfiguration()) - .withExecutor(Executors.newSingleThreadExecutor()) - .withName("test") - .withVersion(HttpClient.Version.HTTP_2) - .build(); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + 39873 + "/failure")) - .GET() - .build(); - - try { - client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - throw new AssertionError("Should have failed!"); - } catch (CompletionException e) { - assertThat(e.getCause()).isInstanceOf(IOException.class); - // good - } - - try { - client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - throw new AssertionError("Should have failed!"); - } catch (CompletionException e) { - assertThat(e.getCause()).isInstanceOf(IOException.class); - // good - } - - try { - client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - throw new AssertionError("Should have failed!"); - } catch (CompletionException e) { - assertThat(e.getCause()).isInstanceOf(CallNotPermittedException.class); - // good - } - - Thread.sleep(1001); - - try { - client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - throw new AssertionError("Should have failed!"); - } catch (CompletionException e) { - assertThat(e.getCause()).isInstanceOf(IOException.class); - // good - } - - try { - client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join(); - throw new AssertionError("Should have failed!"); - } catch (CompletionException e) { - assertThat(e.getCause()).isInstanceOf(CallNotPermittedException.class); - // good - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/limits/DynamicRateLimitsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/limits/DynamicRateLimitsTest.java deleted file mode 100644 index 338a6423b..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/limits/DynamicRateLimitsTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.whispersystems.textsecuregcm.tests.limits; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertNotSame; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.configuration.RateLimitsConfiguration.RateLimitConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRateLimitsConfiguration; -import org.whispersystems.textsecuregcm.limits.DynamicRateLimiters; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -class DynamicRateLimitsTest { - - private DynamicConfigurationManager dynamicConfig; - private FaultTolerantRedisCluster redisCluster; - - @BeforeEach - void setup() { - this.dynamicConfig = mock(DynamicConfigurationManager.class); - this.redisCluster = mock(FaultTolerantRedisCluster.class); - - DynamicConfiguration defaultConfig = new DynamicConfiguration(); - when(dynamicConfig.getConfiguration()).thenReturn(defaultConfig); - - } - - @Test - void testUnchangingConfiguration() { - DynamicRateLimiters rateLimiters = new DynamicRateLimiters(redisCluster, dynamicConfig); - - RateLimiter limiter = rateLimiters.getRateLimitResetLimiter(); - - assertThat(limiter.getBucketSize()).isEqualTo(dynamicConfig.getConfiguration().getLimits().getRateLimitReset().getBucketSize()); - assertThat(limiter.getLeakRatePerMinute()).isEqualTo(dynamicConfig.getConfiguration().getLimits().getRateLimitReset().getLeakRatePerMinute()); - assertSame(rateLimiters.getRateLimitResetLimiter(), limiter); - } - - @Test - void testChangingConfiguration() { - DynamicConfiguration configuration = mock(DynamicConfiguration.class); - DynamicRateLimitsConfiguration limitsConfiguration = mock(DynamicRateLimitsConfiguration.class); - - when(configuration.getLimits()).thenReturn(limitsConfiguration); - when(limitsConfiguration.getRecaptchaChallengeAttempt()).thenReturn(new RateLimitConfiguration()); - when(limitsConfiguration.getRecaptchaChallengeSuccess()).thenReturn(new RateLimitConfiguration()); - when(limitsConfiguration.getPushChallengeAttempt()).thenReturn(new RateLimitConfiguration()); - when(limitsConfiguration.getPushChallengeSuccess()).thenReturn(new RateLimitConfiguration()); - - final RateLimitConfiguration initialRateLimitConfiguration = new RateLimitConfiguration(4, 1); - when(limitsConfiguration.getRateLimitReset()).thenReturn(initialRateLimitConfiguration); - - when(dynamicConfig.getConfiguration()).thenReturn(configuration); - - DynamicRateLimiters rateLimiters = new DynamicRateLimiters(redisCluster, dynamicConfig); - - RateLimiter limiter = rateLimiters.getRateLimitResetLimiter(); - - assertThat(limiter.getBucketSize()).isEqualTo(4); - assertThat(limiter.getLeakRatePerMinute()).isEqualTo(1); - assertSame(rateLimiters.getRateLimitResetLimiter(), limiter); - - when(limitsConfiguration.getRateLimitReset()).thenReturn(new RateLimitConfiguration(17, 19)); - - RateLimiter changed = rateLimiters.getRateLimitResetLimiter(); - - assertThat(changed.getBucketSize()).isEqualTo(17); - assertThat(changed.getLeakRatePerMinute()).isEqualTo(19); - assertNotSame(limiter, changed); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/limits/LeakyBucketTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/limits/LeakyBucketTest.java deleted file mode 100644 index 619e7b34d..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/limits/LeakyBucketTest.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.limits; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.limits.LeakyBucket; - -class LeakyBucketTest { - - @Test - void testFull() { - LeakyBucket leakyBucket = new LeakyBucket(2, 1.0 / 2.0); - - assertTrue(leakyBucket.add(1)); - assertTrue(leakyBucket.add(1)); - assertFalse(leakyBucket.add(1)); - - leakyBucket = new LeakyBucket(2, 1.0 / 2.0); - - assertTrue(leakyBucket.add(2)); - assertFalse(leakyBucket.add(1)); - assertFalse(leakyBucket.add(2)); - } - - @Test - void testLapseRate() throws IOException { - ObjectMapper mapper = new ObjectMapper(); - String serialized = "{\"bucketSize\":2,\"leakRatePerMillis\":8.333333333333334E-6,\"spaceRemaining\":0,\"lastUpdateTimeMillis\":" + (System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(2)) + "}"; - - LeakyBucket leakyBucket = LeakyBucket.fromSerialized(mapper, serialized); - assertTrue(leakyBucket.add(1)); - - String serializedAgain = leakyBucket.serialize(mapper); - LeakyBucket leakyBucketAgain = LeakyBucket.fromSerialized(mapper, serializedAgain); - - assertFalse(leakyBucketAgain.add(1)); - } - - @Test - void testLapseShort() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - String serialized = "{\"bucketSize\":2,\"leakRatePerMillis\":8.333333333333334E-6,\"spaceRemaining\":0,\"lastUpdateTimeMillis\":" + (System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1)) + "}"; - - LeakyBucket leakyBucket = LeakyBucket.fromSerialized(mapper, serialized); - assertFalse(leakyBucket.add(1)); - } - - @Test - void testGetTimeUntilSpaceAvailable() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - - { - String serialized = "{\"bucketSize\":2,\"leakRatePerMillis\":8.333333333333334E-6,\"spaceRemaining\":2,\"lastUpdateTimeMillis\":" + (System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1)) + "}"; - - LeakyBucket leakyBucket = LeakyBucket.fromSerialized(mapper, serialized); - - assertEquals(Duration.ZERO, leakyBucket.getTimeUntilSpaceAvailable(1)); - assertThrows(IllegalArgumentException.class, () -> leakyBucket.getTimeUntilSpaceAvailable(5000)); - } - - { - String serialized = "{\"bucketSize\":2,\"leakRatePerMillis\":8.333333333333334E-6,\"spaceRemaining\":0,\"lastUpdateTimeMillis\":" + (System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1)) + "}"; - - LeakyBucket leakyBucket = LeakyBucket.fromSerialized(mapper, serialized); - - Duration timeUntilSpaceAvailable = leakyBucket.getTimeUntilSpaceAvailable(1); - - // TODO Refactor LeakyBucket to be more test-friendly and accept a Clock - assertTrue(timeUntilSpaceAvailable.compareTo(Duration.ofMillis(119_000)) > 0); - assertTrue(timeUntilSpaceAvailable.compareTo(Duration.ofMinutes(2)) <= 0); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/redis/ReplicatedJedisPoolTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/redis/ReplicatedJedisPoolTest.java deleted file mode 100644 index bba66d03f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/redis/ReplicatedJedisPoolTest.java +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.redis; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import io.github.resilience4j.circuitbreaker.CallNotPermittedException; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedList; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; -import org.whispersystems.textsecuregcm.redis.ReplicatedJedisPool; -import redis.clients.jedis.Jedis; -import redis.clients.jedis.JedisPool; -import redis.clients.jedis.exceptions.JedisException; - -class ReplicatedJedisPoolTest { - - @Test - void testWriteCheckoutNoSlaves() { - JedisPool master = mock(JedisPool.class); - - try { - new ReplicatedJedisPool("testWriteCheckoutNoSlaves", master, new LinkedList<>(), new CircuitBreakerConfiguration()); - throw new AssertionError(); - } catch (Exception e) { - // good - } - } - - @Test - void testWriteCheckoutWithSlaves() { - JedisPool master = mock(JedisPool.class); - JedisPool slave = mock(JedisPool.class); - Jedis instance = mock(Jedis.class ); - - when(master.getResource()).thenReturn(instance); - - ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testWriteCheckoutWithSlaves", master, Collections.singletonList(slave), new CircuitBreakerConfiguration()); - Jedis writeResource = replicatedJedisPool.getWriteResource(); - - assertThat(writeResource).isEqualTo(instance); - verify(master, times(1)).getResource(); - } - - @Test - void testReadCheckouts() { - JedisPool master = mock(JedisPool.class); - JedisPool slaveOne = mock(JedisPool.class); - JedisPool slaveTwo = mock(JedisPool.class); - Jedis instanceOne = mock(Jedis.class ); - Jedis instanceTwo = mock(Jedis.class ); - - when(slaveOne.getResource()).thenReturn(instanceOne); - when(slaveTwo.getResource()).thenReturn(instanceTwo); - - ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testReadCheckouts", master, Arrays.asList(slaveOne, slaveTwo), new CircuitBreakerConfiguration()); - - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceOne); - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceTwo); - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceOne); - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceTwo); - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceOne); - - verifyNoMoreInteractions(master); - } - - @Test - void testBrokenReadCheckout() { - JedisPool master = mock(JedisPool.class); - JedisPool slaveOne = mock(JedisPool.class); - JedisPool slaveTwo = mock(JedisPool.class); - Jedis instanceTwo = mock(Jedis.class ); - - when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!")); - when(slaveTwo.getResource()).thenReturn(instanceTwo); - - ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testBrokenReadCheckout", master, Arrays.asList(slaveOne, slaveTwo), new CircuitBreakerConfiguration()); - - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceTwo); - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceTwo); - assertThat(replicatedJedisPool.getReadResource()).isEqualTo(instanceTwo); - - verifyNoMoreInteractions(master); - } - - @Test - void testAllBrokenReadCheckout() { - JedisPool master = mock(JedisPool.class); - JedisPool slaveOne = mock(JedisPool.class); - JedisPool slaveTwo = mock(JedisPool.class); - - when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!")); - when(slaveTwo.getResource()).thenThrow(new JedisException("Also failed!")); - - ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testAllBrokenReadCheckout", master, Arrays.asList(slaveOne, slaveTwo), new CircuitBreakerConfiguration()); - - try { - replicatedJedisPool.getReadResource(); - throw new AssertionError(); - } catch (Exception e) { - // good - } - - verifyNoMoreInteractions(master); - } - - @Test - void testCircuitBreakerOpen() { - CircuitBreakerConfiguration configuration = new CircuitBreakerConfiguration(); - configuration.setFailureRateThreshold(50); - configuration.setSlidingWindowSize(2); - configuration.setSlidingWindowMinimumNumberOfCalls(2); - - JedisPool master = mock(JedisPool.class); - JedisPool slaveOne = mock(JedisPool.class); - JedisPool slaveTwo = mock(JedisPool.class); - - when(master.getResource()).thenReturn(null); - when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!")); - when(slaveTwo.getResource()).thenThrow(new JedisException("Also failed!")); - - ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testCircuitBreakerOpen", master, - Arrays.asList(slaveOne, slaveTwo), configuration); - replicatedJedisPool.getWriteResource(); - - when(master.getResource()).thenThrow(new JedisException("Master broken!")); - - try { - replicatedJedisPool.getWriteResource(); - throw new AssertionError(); - } catch (JedisException exception) { - // good - } - - try { - replicatedJedisPool.getWriteResource(); - throw new AssertionError(); - } catch (CallNotPermittedException e) { - // good - } - } - - @Test - void testCircuitBreakerHalfOpen() throws InterruptedException { - CircuitBreakerConfiguration configuration = new CircuitBreakerConfiguration(); - configuration.setFailureRateThreshold(50); - configuration.setSlidingWindowSize(2); - configuration.setSlidingWindowMinimumNumberOfCalls(2); - configuration.setPermittedNumberOfCallsInHalfOpenState(1); - configuration.setWaitDurationInOpenStateInSeconds(1); - - JedisPool master = mock(JedisPool.class); - JedisPool slaveOne = mock(JedisPool.class); - JedisPool slaveTwo = mock(JedisPool.class); - - when(master.getResource()).thenThrow(new JedisException("Master broken!")); - when(slaveOne.getResource()).thenThrow(new JedisException("Connection failed!")); - when(slaveTwo.getResource()).thenThrow(new JedisException("Also failed!")); - - ReplicatedJedisPool replicatedJedisPool = new ReplicatedJedisPool("testCircuitBreakerHalfOpen", master, Arrays.asList(slaveOne, slaveTwo), configuration); - - try { - replicatedJedisPool.getWriteResource(); - throw new AssertionError(); - } catch (JedisException exception) { - // good - } - - try { - replicatedJedisPool.getWriteResource(); - throw new AssertionError(); - } catch (JedisException exception) { - // good - } - - try { - replicatedJedisPool.getWriteResource(); - throw new AssertionError(); - } catch (CallNotPermittedException e) { - // good - } - - Thread.sleep(1100); - - try { - replicatedJedisPool.getWriteResource(); - throw new AssertionError(); - } catch (JedisException exception) { - // good - } - - try { - replicatedJedisPool.getWriteResource(); - throw new AssertionError(); - } catch (CallNotPermittedException e) { - // good - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/s3/PolicySignerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/s3/PolicySignerTest.java deleted file mode 100644 index 5ba3ef827..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/s3/PolicySignerTest.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.s3; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.s3.PolicySigner; - -class PolicySignerTest { - - @Test - void testSignature() { - Instant time = Instant.parse("2015-12-29T00:00:00Z"); - ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(time, ZoneOffset.UTC); - String encodedPolicy = "eyAiZXhwaXJhdGlvbiI6ICIyMDE1LTEyLTMwVDEyOjAwOjAwLjAwMFoiLA0KICAiY29uZGl0aW9ucyI6IFsNCiAgICB7ImJ1Y2tldCI6ICJzaWd2NGV4YW1wbGVidWNrZXQifSwNCiAgICBbInN0YXJ0cy13aXRoIiwgIiRrZXkiLCAidXNlci91c2VyMS8iXSwNCiAgICB7ImFjbCI6ICJwdWJsaWMtcmVhZCJ9LA0KICAgIHsic3VjY2Vzc19hY3Rpb25fcmVkaXJlY3QiOiAiaHR0cDovL3NpZ3Y0ZXhhbXBsZWJ1Y2tldC5zMy5hbWF6b25hd3MuY29tL3N1Y2Nlc3NmdWxfdXBsb2FkLmh0bWwifSwNCiAgICBbInN0YXJ0cy13aXRoIiwgIiRDb250ZW50LVR5cGUiLCAiaW1hZ2UvIl0sDQogICAgeyJ4LWFtei1tZXRhLXV1aWQiOiAiMTQzNjUxMjM2NTEyNzQifSwNCiAgICB7IngtYW16LXNlcnZlci1zaWRlLWVuY3J5cHRpb24iOiAiQUVTMjU2In0sDQogICAgWyJzdGFydHMtd2l0aCIsICIkeC1hbXotbWV0YS10YWciLCAiIl0sDQoNCiAgICB7IngtYW16LWNyZWRlbnRpYWwiOiAiQUtJQUlPU0ZPRE5ON0VYQU1QTEUvMjAxNTEyMjkvdXMtZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LA0KICAgIHsieC1hbXotYWxnb3JpdGhtIjogIkFXUzQtSE1BQy1TSEEyNTYifSwNCiAgICB7IngtYW16LWRhdGUiOiAiMjAxNTEyMjlUMDAwMDAwWiIgfQ0KICBdDQp9"; - PolicySigner policySigner = new PolicySigner("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "us-east-1"); - - assertEquals(policySigner.getSignature(zonedDateTime, encodedPolicy), "8afdbf4008c03f22c2cd3cdb72e4afbb1f6a588f3255ac628749a66d7f09699e"); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountDatabaseCrawlerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountDatabaseCrawlerTest.java deleted file mode 100644 index 27a9cbbdd..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountDatabaseCrawlerTest.java +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountCrawlChunk; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawler; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerCache; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerListener; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerRestartException; -import org.whispersystems.textsecuregcm.storage.AccountsManager; - -class AccountDatabaseCrawlerTest { - - private static final UUID ACCOUNT1 = UUID.randomUUID(); - private static final UUID ACCOUNT2 = UUID.randomUUID(); - - private static final int CHUNK_SIZE = 1000; - private static final long CHUNK_INTERVAL_MS = 30_000L; - - private final Account account1 = mock(Account.class); - private final Account account2 = mock(Account.class); - - private final AccountsManager accounts = mock(AccountsManager.class); - private final AccountDatabaseCrawlerListener listener = mock(AccountDatabaseCrawlerListener.class); - private final AccountDatabaseCrawlerCache cache = mock(AccountDatabaseCrawlerCache.class); - - private final AccountDatabaseCrawler crawler = - new AccountDatabaseCrawler("test", accounts, cache, List.of(listener), CHUNK_SIZE, CHUNK_INTERVAL_MS); - - @BeforeEach - void setup() { - when(account1.getUuid()).thenReturn(ACCOUNT1); - when(account2.getUuid()).thenReturn(ACCOUNT2); - - when(accounts.getAllFromDynamo(anyInt())).thenReturn( - new AccountCrawlChunk(List.of(account1, account2), ACCOUNT2)); - when(accounts.getAllFromDynamo(eq(ACCOUNT1), anyInt())).thenReturn( - new AccountCrawlChunk(List.of(account2), ACCOUNT2)); - when(accounts.getAllFromDynamo(eq(ACCOUNT2), anyInt())).thenReturn( - new AccountCrawlChunk(Collections.emptyList(), null)); - - when(cache.claimActiveWork(any(), anyLong())).thenReturn(true); - when(cache.isAccelerated()).thenReturn(false); - - } - - @Test - void testCrawlStart() throws AccountDatabaseCrawlerRestartException { - when(cache.getLastUuid()).thenReturn(Optional.empty()); - when(cache.getLastUuidDynamo()).thenReturn(Optional.empty()); - - boolean accelerated = crawler.doPeriodicWork(); - assertThat(accelerated).isFalse(); - - verify(cache, times(1)).claimActiveWork(any(String.class), anyLong()); - verify(cache, times(0)).getLastUuid(); - verify(cache, times(1)).getLastUuidDynamo(); - verify(listener, times(1)).onCrawlStart(); - verify(accounts, times(1)).getAllFromDynamo(eq(CHUNK_SIZE)); - verify(accounts, times(0)).getAllFromDynamo(any(UUID.class), eq(CHUNK_SIZE)); - verify(account1, times(0)).getUuid(); - verify(listener, times(1)).timeAndProcessCrawlChunk(eq(Optional.empty()), eq(List.of(account1, account2))); - verify(cache, times(0)).setLastUuid(eq(Optional.of(ACCOUNT2))); - verify(cache, times(1)).setLastUuidDynamo(eq(Optional.of(ACCOUNT2))); - verify(cache, times(1)).isAccelerated(); - verify(cache, times(1)).releaseActiveWork(any(String.class)); - - verifyNoMoreInteractions(account1); - verifyNoMoreInteractions(account2); - verifyNoMoreInteractions(accounts); - verifyNoMoreInteractions(listener); - verifyNoMoreInteractions(cache); - } - - @Test - void testCrawlChunk() throws AccountDatabaseCrawlerRestartException { - when(cache.getLastUuid()).thenReturn(Optional.of(ACCOUNT1)); - when(cache.getLastUuidDynamo()).thenReturn(Optional.of(ACCOUNT1)); - - boolean accelerated = crawler.doPeriodicWork(); - assertThat(accelerated).isFalse(); - - verify(cache, times(1)).claimActiveWork(any(String.class), anyLong()); - verify(cache, times(0)).getLastUuid(); - verify(cache, times(1)).getLastUuidDynamo(); - verify(accounts, times(0)).getAllFromDynamo(eq(CHUNK_SIZE)); - verify(accounts, times(1)).getAllFromDynamo(eq(ACCOUNT1), eq(CHUNK_SIZE)); - verify(listener, times(1)).timeAndProcessCrawlChunk(eq(Optional.of(ACCOUNT1)), eq(List.of(account2))); - verify(cache, times(0)).setLastUuid(eq(Optional.of(ACCOUNT2))); - verify(cache, times(1)).setLastUuidDynamo(eq(Optional.of(ACCOUNT2))); - verify(cache, times(1)).isAccelerated(); - verify(cache, times(1)).releaseActiveWork(any(String.class)); - - verifyNoInteractions(account1); - - verifyNoMoreInteractions(account2); - verifyNoMoreInteractions(accounts); - verifyNoMoreInteractions(listener); - verifyNoMoreInteractions(cache); - } - - @Test - void testCrawlChunkAccelerated() throws AccountDatabaseCrawlerRestartException { - when(cache.isAccelerated()).thenReturn(true); - when(cache.getLastUuid()).thenReturn(Optional.of(ACCOUNT1)); - when(cache.getLastUuidDynamo()).thenReturn(Optional.of(ACCOUNT1)); - - boolean accelerated = crawler.doPeriodicWork(); - assertThat(accelerated).isTrue(); - - verify(cache, times(1)).claimActiveWork(any(String.class), anyLong()); - verify(cache, times(0)).getLastUuid(); - verify(cache, times(1)).getLastUuidDynamo(); - verify(accounts, times(0)).getAllFromDynamo(eq(CHUNK_SIZE)); - verify(accounts, times(1)).getAllFromDynamo(eq(ACCOUNT1), eq(CHUNK_SIZE)); - verify(listener, times(1)).timeAndProcessCrawlChunk(eq(Optional.of(ACCOUNT1)), eq(List.of(account2))); - verify(cache, times(0)).setLastUuid(eq(Optional.of(ACCOUNT2))); - verify(cache, times(1)).setLastUuidDynamo(eq(Optional.of(ACCOUNT2))); - verify(cache, times(1)).isAccelerated(); - verify(cache, times(1)).releaseActiveWork(any(String.class)); - - verifyNoInteractions(account1); - - verifyNoMoreInteractions(account2); - verifyNoMoreInteractions(accounts); - verifyNoMoreInteractions(listener); - verifyNoMoreInteractions(cache); - } - - @Test - void testCrawlChunkRestart() throws AccountDatabaseCrawlerRestartException { - when(cache.getLastUuid()).thenReturn(Optional.of(ACCOUNT1)); - when(cache.getLastUuidDynamo()).thenReturn(Optional.of(ACCOUNT1)); - doThrow(AccountDatabaseCrawlerRestartException.class).when(listener) - .timeAndProcessCrawlChunk(eq(Optional.of(ACCOUNT1)), eq(List.of(account2))); - - boolean accelerated = crawler.doPeriodicWork(); - assertThat(accelerated).isFalse(); - - verify(cache, times(1)).claimActiveWork(any(String.class), anyLong()); - verify(cache, times(0)).getLastUuid(); - verify(cache, times(1)).getLastUuidDynamo(); - verify(accounts, times(0)).getAllFromDynamo(eq(CHUNK_SIZE)); - verify(accounts, times(1)).getAllFromDynamo(eq(ACCOUNT1), eq(CHUNK_SIZE)); - verify(account2, times(0)).getNumber(); - verify(listener, times(1)).timeAndProcessCrawlChunk(eq(Optional.of(ACCOUNT1)), eq(List.of(account2))); - verify(cache, times(0)).setLastUuid(eq(Optional.empty())); - verify(cache, times(1)).setLastUuidDynamo(eq(Optional.empty())); - verify(cache, times(1)).setAccelerated(false); - verify(cache, times(1)).isAccelerated(); - verify(cache, times(1)).releaseActiveWork(any(String.class)); - - verifyNoInteractions(account1); - - verifyNoMoreInteractions(account2); - verifyNoMoreInteractions(accounts); - verifyNoMoreInteractions(listener); - verifyNoMoreInteractions(cache); - } - - @Test - void testCrawlEnd() { - when(cache.getLastUuid()).thenReturn(Optional.of(ACCOUNT2)); - when(cache.getLastUuidDynamo()).thenReturn(Optional.of(ACCOUNT2)); - - boolean accelerated = crawler.doPeriodicWork(); - assertThat(accelerated).isFalse(); - - verify(cache, times(1)).claimActiveWork(any(String.class), anyLong()); - verify(cache, times(0)).getLastUuid(); - verify(cache, times(1)).getLastUuidDynamo(); - verify(accounts, times(0)).getAllFromDynamo(eq(CHUNK_SIZE)); - verify(accounts, times(1)).getAllFromDynamo(eq(ACCOUNT2), eq(CHUNK_SIZE)); - verify(account1, times(0)).getNumber(); - verify(account2, times(0)).getNumber(); - verify(listener, times(1)).onCrawlEnd(eq(Optional.of(ACCOUNT2))); - verify(cache, times(0)).setLastUuid(eq(Optional.empty())); - verify(cache, times(1)).setLastUuidDynamo(eq(Optional.empty())); - verify(cache, times(1)).setAccelerated(false); - verify(cache, times(1)).isAccelerated(); - verify(cache, times(1)).releaseActiveWork(any(String.class)); - - verifyNoInteractions(account1); - verifyNoInteractions(account2); - - verifyNoMoreInteractions(accounts); - verifyNoMoreInteractions(listener); - verifyNoMoreInteractions(cache); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountTest.java deleted file mode 100644 index 609de3604..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/AccountTest.java +++ /dev/null @@ -1,434 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.tests.util.DevicesHelper.createDevice; -import static org.whispersystems.textsecuregcm.tests.util.DevicesHelper.setEnabled; - -import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Instant; -import java.util.Collections; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountBadge; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.Device.DeviceCapabilities; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.util.TestClock; - -class AccountTest { - - private final Device oldMasterDevice = mock(Device.class); - private final Device recentMasterDevice = mock(Device.class); - private final Device agingSecondaryDevice = mock(Device.class); - private final Device recentSecondaryDevice = mock(Device.class); - private final Device oldSecondaryDevice = mock(Device.class); - - private final Device senderKeyCapableDevice = mock(Device.class); - private final Device senderKeyIncapableDevice = mock(Device.class); - private final Device senderKeyIncapableExpiredDevice = mock(Device.class); - - private final Device announcementGroupCapableDevice = mock(Device.class); - private final Device announcementGroupIncapableDevice = mock(Device.class); - private final Device announcementGroupIncapableExpiredDevice = mock(Device.class); - - private final Device changeNumberCapableDevice = mock(Device.class); - private final Device changeNumberIncapableDevice = mock(Device.class); - private final Device changeNumberIncapableExpiredDevice = mock(Device.class); - - private final Device pniCapableDevice = mock(Device.class); - private final Device pniIncapableDevice = mock(Device.class); - private final Device pniIncapableExpiredDevice = mock(Device.class); - - private final Device storiesCapableDevice = mock(Device.class); - private final Device storiesIncapableDevice = mock(Device.class); - private final Device storiesIncapableExpiredDevice = mock(Device.class); - - private final Device giftBadgesCapableDevice = mock(Device.class); - private final Device giftBadgesIncapableDevice = mock(Device.class); - private final Device giftBadgesIncapableExpiredDevice = mock(Device.class); - - private final Device paymentActivationCapableDevice = mock(Device.class); - private final Device paymentActivationIncapableDevice = mock(Device.class); - private final Device paymentActivationIncapableExpiredDevice = mock(Device.class); - - @BeforeEach - void setup() { - when(oldMasterDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(366)); - when(oldMasterDevice.isEnabled()).thenReturn(true); - when(oldMasterDevice.getId()).thenReturn(Device.MASTER_ID); - - when(recentMasterDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)); - when(recentMasterDevice.isEnabled()).thenReturn(true); - when(recentMasterDevice.getId()).thenReturn(Device.MASTER_ID); - - when(agingSecondaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31)); - when(agingSecondaryDevice.isEnabled()).thenReturn(false); - when(agingSecondaryDevice.getId()).thenReturn(2L); - - when(recentSecondaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)); - when(recentSecondaryDevice.isEnabled()).thenReturn(true); - when(recentSecondaryDevice.getId()).thenReturn(2L); - - when(oldSecondaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(366)); - when(oldSecondaryDevice.isEnabled()).thenReturn(false); - when(oldSecondaryDevice.getId()).thenReturn(2L); - - when(senderKeyCapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(senderKeyCapableDevice.isEnabled()).thenReturn(true); - - when(senderKeyIncapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, false, false, false, false, false, false, false)); - when(senderKeyIncapableDevice.isEnabled()).thenReturn(true); - - when(senderKeyIncapableExpiredDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, false, false, false, false, false, false, false)); - when(senderKeyIncapableExpiredDevice.isEnabled()).thenReturn(false); - - when(announcementGroupCapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, true, false, false, false, false, false)); - when(announcementGroupCapableDevice.isEnabled()).thenReturn(true); - - when(announcementGroupIncapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(announcementGroupIncapableDevice.isEnabled()).thenReturn(true); - - when(announcementGroupIncapableExpiredDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(announcementGroupIncapableExpiredDevice.isEnabled()).thenReturn(false); - - when(changeNumberCapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, true, false, false, false, false)); - when(changeNumberCapableDevice.isEnabled()).thenReturn(true); - - when(changeNumberIncapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(changeNumberIncapableDevice.isEnabled()).thenReturn(true); - - when(changeNumberIncapableExpiredDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(changeNumberIncapableExpiredDevice.isEnabled()).thenReturn(false); - - when(pniCapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, true, false, false, false)); - when(pniCapableDevice.isEnabled()).thenReturn(true); - - when(pniIncapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(pniIncapableDevice.isEnabled()).thenReturn(true); - - when(pniIncapableExpiredDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(pniIncapableExpiredDevice.isEnabled()).thenReturn(false); - - when(storiesCapableDevice.getId()).thenReturn(1L); - when(storiesCapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, true, false, false)); - when(storiesCapableDevice.isEnabled()).thenReturn(true); - - when(storiesCapableDevice.getId()).thenReturn(2L); - when(storiesIncapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(storiesIncapableDevice.isEnabled()).thenReturn(true); - - when(storiesCapableDevice.getId()).thenReturn(3L); - when(storiesIncapableExpiredDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, false, false, false, false, false, false)); - when(storiesIncapableExpiredDevice.isEnabled()).thenReturn(false); - - when(giftBadgesCapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, true, true, true, true, true, false)); - when(giftBadgesCapableDevice.isEnabled()).thenReturn(true); - when(giftBadgesIncapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, true, true, true, true, false, false)); - when(giftBadgesIncapableDevice.isEnabled()).thenReturn(true); - when(giftBadgesIncapableExpiredDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, true, true, true, true, false, false)); - when(giftBadgesIncapableExpiredDevice.isEnabled()).thenReturn(false); - - when(paymentActivationCapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, true, true, true, true, true, true)); - when(paymentActivationCapableDevice.isEnabled()).thenReturn(true); - when(paymentActivationIncapableDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, true, true, true, true, false, false)); - when(paymentActivationIncapableDevice.isEnabled()).thenReturn(true); - when(paymentActivationIncapableExpiredDevice.getCapabilities()).thenReturn( - new DeviceCapabilities(true, true, true, true, true, true, true, false, false)); - when(paymentActivationIncapableExpiredDevice.isEnabled()).thenReturn(false); - - } - - @Test - void testIsEnabled() { - final Device enabledMasterDevice = mock(Device.class); - final Device enabledLinkedDevice = mock(Device.class); - final Device disabledMasterDevice = mock(Device.class); - final Device disabledLinkedDevice = mock(Device.class); - - when(enabledMasterDevice.isEnabled()).thenReturn(true); - when(enabledLinkedDevice.isEnabled()).thenReturn(true); - when(disabledMasterDevice.isEnabled()).thenReturn(false); - when(disabledLinkedDevice.isEnabled()).thenReturn(false); - - when(enabledMasterDevice.getId()).thenReturn(1L); - when(enabledLinkedDevice.getId()).thenReturn(2L); - when(disabledMasterDevice.getId()).thenReturn(1L); - when(disabledLinkedDevice.getId()).thenReturn(2L); - - assertTrue(AccountsHelper.generateTestAccount("+14151234567", List.of(enabledMasterDevice)).isEnabled()); - assertTrue(AccountsHelper.generateTestAccount("+14151234567", List.of(enabledMasterDevice, enabledLinkedDevice)).isEnabled()); - assertTrue(AccountsHelper.generateTestAccount("+14151234567", List.of(enabledMasterDevice, disabledLinkedDevice)).isEnabled()); - assertFalse(AccountsHelper.generateTestAccount("+14151234567", List.of(disabledMasterDevice)).isEnabled()); - assertFalse(AccountsHelper.generateTestAccount("+14151234567", List.of(disabledMasterDevice, enabledLinkedDevice)).isEnabled()); - assertFalse(AccountsHelper.generateTestAccount("+14151234567", List.of(disabledMasterDevice, disabledLinkedDevice)).isEnabled()); - } - - @Test - void testIsTransferSupported() { - final Device transferCapableMasterDevice = mock(Device.class); - final Device nonTransferCapableMasterDevice = mock(Device.class); - final Device transferCapableLinkedDevice = mock(Device.class); - - final DeviceCapabilities transferCapabilities = mock(DeviceCapabilities.class); - final DeviceCapabilities nonTransferCapabilities = mock(DeviceCapabilities.class); - - when(transferCapableMasterDevice.getId()).thenReturn(1L); - when(transferCapableMasterDevice.isMaster()).thenReturn(true); - when(transferCapableMasterDevice.getCapabilities()).thenReturn(transferCapabilities); - - when(nonTransferCapableMasterDevice.getId()).thenReturn(1L); - when(nonTransferCapableMasterDevice.isMaster()).thenReturn(true); - when(nonTransferCapableMasterDevice.getCapabilities()).thenReturn(nonTransferCapabilities); - - when(transferCapableLinkedDevice.getId()).thenReturn(2L); - when(transferCapableLinkedDevice.isMaster()).thenReturn(false); - when(transferCapableLinkedDevice.getCapabilities()).thenReturn(transferCapabilities); - - when(transferCapabilities.isTransfer()).thenReturn(true); - when(nonTransferCapabilities.isTransfer()).thenReturn(false); - - { - final Account transferableMasterAccount = - AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(transferCapableMasterDevice), "1234".getBytes()); - - assertTrue(transferableMasterAccount.isTransferSupported()); - } - - { - final Account nonTransferableMasterAccount = - AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(nonTransferCapableMasterDevice), "1234".getBytes()); - - assertFalse(nonTransferableMasterAccount.isTransferSupported()); - } - - { - final Account transferableLinkedAccount = AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(nonTransferCapableMasterDevice, transferCapableLinkedDevice), "1234".getBytes()); - - assertFalse(transferableLinkedAccount.isTransferSupported()); - } - } - - @Test - void testDiscoverableByPhoneNumber() { - final Account account = AccountsHelper.generateTestAccount("+14152222222", UUID.randomUUID(), UUID.randomUUID(), List.of(recentMasterDevice), - "1234".getBytes()); - - assertTrue(account.isDiscoverableByPhoneNumber(), - "Freshly-loaded legacy accounts should be discoverable by phone number."); - - account.setDiscoverableByPhoneNumber(false); - assertFalse(account.isDiscoverableByPhoneNumber()); - - account.setDiscoverableByPhoneNumber(true); - assertTrue(account.isDiscoverableByPhoneNumber()); - } - - @Test - void isSenderKeySupported() { - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), List.of(senderKeyCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isSenderKeySupported()).isTrue(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - List.of(senderKeyCapableDevice, senderKeyIncapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isSenderKeySupported()).isFalse(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(senderKeyCapableDevice, senderKeyIncapableExpiredDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isSenderKeySupported()).isTrue(); - } - - @Test - void isAnnouncementGroupSupported() { - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(announcementGroupCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isAnnouncementGroupSupported()).isTrue(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(announcementGroupCapableDevice, announcementGroupIncapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isAnnouncementGroupSupported()).isFalse(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(announcementGroupCapableDevice, announcementGroupIncapableExpiredDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isAnnouncementGroupSupported()).isTrue(); - } - - @Test - void isChangeNumberSupported() { - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(changeNumberCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isChangeNumberSupported()).isTrue(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(changeNumberCapableDevice, changeNumberIncapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isChangeNumberSupported()).isFalse(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(changeNumberCapableDevice, changeNumberIncapableExpiredDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isChangeNumberSupported()).isTrue(); - } - - @Test - void isPniSupported() { - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(pniCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isPniSupported()).isTrue(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(pniCapableDevice, pniIncapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isPniSupported()).isFalse(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(pniCapableDevice, pniIncapableExpiredDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isPniSupported()).isTrue(); - } - - @Test - void isStoriesSupported() { - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(storiesCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isStoriesSupported()).isTrue(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(storiesCapableDevice, storiesIncapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isStoriesSupported()).isFalse(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), - UUID.randomUUID(), List.of(storiesCapableDevice, storiesIncapableExpiredDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isStoriesSupported()).isTrue(); - } - - @Test - void isGiftBadgesSupported() { - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - List.of(giftBadgesCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isGiftBadgesSupported()).isTrue(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - List.of(giftBadgesCapableDevice, giftBadgesIncapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isGiftBadgesSupported()).isFalse(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - List.of(giftBadgesCapableDevice, giftBadgesIncapableExpiredDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isGiftBadgesSupported()).isTrue(); - } - - @Test - void isPaymentActivationSupported() { - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - List.of(paymentActivationCapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isPaymentActivationSupported()).isTrue(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - List.of(paymentActivationCapableDevice, paymentActivationIncapableDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isPaymentActivationSupported()).isFalse(); - assertThat(AccountsHelper.generateTestAccount("+18005551234", UUID.randomUUID(), UUID.randomUUID(), - List.of(paymentActivationCapableDevice, paymentActivationIncapableExpiredDevice), - "1234".getBytes(StandardCharsets.UTF_8)).isPaymentActivationSupported()).isTrue(); - } - - @Test - void stale() { - final Account account = AccountsHelper.generateTestAccount("+14151234567", UUID.randomUUID(), UUID.randomUUID(), Collections.emptyList(), - new byte[0]); - - assertDoesNotThrow(account::getNumber); - - account.markStale(); - - assertThrows(AssertionError.class, account::getNumber); - assertDoesNotThrow(account::getUuid); - } - - @Test - void getNextDeviceId() { - - final List devices = List.of(createDevice(Device.MASTER_ID)); - - final Account account = AccountsHelper.generateTestAccount("+14151234567", UUID.randomUUID(), UUID.randomUUID(), devices, new byte[0]); - - assertThat(account.getNextDeviceId()).isEqualTo(2L); - - account.addDevice(createDevice(2L)); - - assertThat(account.getNextDeviceId()).isEqualTo(3L); - - account.addDevice(createDevice(3L)); - - setEnabled(account.getDevice(2L).orElseThrow(), false); - - assertThat(account.getNextDeviceId()).isEqualTo(4L); - - account.removeDevice(2L); - - assertThat(account.getNextDeviceId()).isEqualTo(2L); - } - - @Test - void replaceDevice() { - final Device firstDevice = createDevice(Device.MASTER_ID); - final Device secondDevice = createDevice(Device.MASTER_ID); - final Account account = AccountsHelper.generateTestAccount("+14151234567", UUID.randomUUID(), UUID.randomUUID(), List.of(firstDevice), new byte[0]); - - assertEquals(List.of(firstDevice), account.getDevices()); - - account.addDevice(secondDevice); - - assertEquals(List.of(secondDevice), account.getDevices()); - } - - @Test - void addAndRemoveBadges() { - final Account account = AccountsHelper.generateTestAccount("+14151234567", UUID.randomUUID(), UUID.randomUUID(), List.of(createDevice(Device.MASTER_ID)), new byte[0]); - final Clock clock = TestClock.pinned(Instant.ofEpochSecond(40)); - - account.addBadge(clock, new AccountBadge("foo", Instant.ofEpochSecond(42), false)); - account.addBadge(clock, new AccountBadge("bar", Instant.ofEpochSecond(44), true)); - account.addBadge(clock, new AccountBadge("baz", Instant.ofEpochSecond(46), true)); - - assertThat(account.getBadges()).hasSize(3); - - account.removeBadge(clock, "baz"); - - assertThat(account.getBadges()).hasSize(2); - - account.addBadge(clock, new AccountBadge("foo", Instant.ofEpochSecond(50), false)); - - assertThat(account.getBadges()).hasSize(2).element(0).satisfies(badge -> { - assertThat(badge.getId()).isEqualTo("foo"); - assertThat(badge.getExpiration().getEpochSecond()).isEqualTo(50); - assertThat(badge.isVisible()).isFalse(); - }); - - account.addBadge(clock, new AccountBadge("foo", Instant.ofEpochSecond(51), true)); - - assertThat(account.getBadges()).hasSize(2).element(0).satisfies(badge -> { - assertThat(badge.getId()).isEqualTo("foo"); - assertThat(badge.getExpiration().getEpochSecond()).isEqualTo(51); - assertThat(badge.isVisible()).isTrue(); - }); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/DirectoryReconcilerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/DirectoryReconcilerTest.java deleted file mode 100644 index 35f5564b7..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/DirectoryReconcilerTest.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationRequest.User; -import org.whispersystems.textsecuregcm.entities.DirectoryReconciliationResponse; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerRestartException; -import org.whispersystems.textsecuregcm.storage.DirectoryReconciler; -import org.whispersystems.textsecuregcm.storage.DirectoryReconciliationClient; -import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; - -class DirectoryReconcilerTest { - - private static final UUID VALID_UUID = UUID.randomUUID(); - private static final String VALID_NUMBER = "+14152222222"; - private static final UUID UNDISCOVERABLE_UUID = UUID.randomUUID(); - private static final String UNDISCOVERABLE_NUMBER = "+14153333333"; - - private final Account visibleAccount = mock(Account.class); - private final Account undiscoverableAccount = mock(Account.class); - private final DirectoryReconciliationClient reconciliationClient = mock(DirectoryReconciliationClient.class); - private final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class); - private final DirectoryReconciler directoryReconciler = new DirectoryReconciler("test", reconciliationClient, - dynamicConfigurationManager); - - private final DirectoryReconciliationResponse successResponse = new DirectoryReconciliationResponse( - DirectoryReconciliationResponse.Status.OK); - - @BeforeEach - void setup() { - when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration()); - - when(visibleAccount.getUuid()).thenReturn(VALID_UUID); - when(visibleAccount.getNumber()).thenReturn(VALID_NUMBER); - when(visibleAccount.shouldBeVisibleInDirectory()).thenReturn(true); - when(undiscoverableAccount.getUuid()).thenReturn(UNDISCOVERABLE_UUID); - when(undiscoverableAccount.getNumber()).thenReturn(UNDISCOVERABLE_NUMBER); - when(undiscoverableAccount.shouldBeVisibleInDirectory()).thenReturn(false); - } - - @Test - void testCrawlChunkValid() throws AccountDatabaseCrawlerRestartException { - - when(reconciliationClient.add(any())).thenReturn(successResponse); - when(reconciliationClient.delete(any())).thenReturn(successResponse); - - directoryReconciler.timeAndProcessCrawlChunk(Optional.of(VALID_UUID), - Arrays.asList(visibleAccount, undiscoverableAccount)); - - ArgumentCaptor chunkRequest = ArgumentCaptor.forClass( - DirectoryReconciliationRequest.class); - verify(reconciliationClient, times(1)).add(chunkRequest.capture()); - - assertThat(chunkRequest.getValue().getUsers()).isEqualTo(List.of(new User(VALID_UUID, VALID_NUMBER))); - - ArgumentCaptor deletesRequest = ArgumentCaptor.forClass( - DirectoryReconciliationRequest.class); - verify(reconciliationClient, times(1)).delete(deletesRequest.capture()); - - assertThat(deletesRequest.getValue().getUsers()).isEqualTo( - List.of(new User(UNDISCOVERABLE_UUID, UNDISCOVERABLE_NUMBER))); - - verifyNoMoreInteractions(reconciliationClient); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesManagerTest.java deleted file mode 100644 index 894f12de0..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/ProfilesManagerTest.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import io.lettuce.core.RedisException; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import java.util.Base64; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; -import org.whispersystems.textsecuregcm.storage.Profiles; -import org.whispersystems.textsecuregcm.storage.ProfilesManager; -import org.whispersystems.textsecuregcm.storage.VersionedProfile; -import org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper; - -public class ProfilesManagerTest { - - private Profiles profiles; - private RedisAdvancedClusterCommands commands; - - private ProfilesManager profilesManager; - - @BeforeEach - void setUp() { - //noinspection unchecked - commands = mock(RedisAdvancedClusterCommands.class); - final FaultTolerantRedisCluster cacheCluster = RedisClusterHelper.builder().stringCommands(commands).build(); - - profiles = mock(Profiles.class); - - profilesManager = new ProfilesManager(profiles, cacheCluster); - } - - @Test - public void testGetProfileInCache() { - UUID uuid = UUID.randomUUID(); - - when(commands.hget(eq("profiles::" + uuid), eq("someversion"))).thenReturn("{\"version\": \"someversion\", \"name\": \"somename\", \"avatar\": \"someavatar\", \"commitment\":\"" + Base64.getEncoder().encodeToString("somecommitment".getBytes()) + "\"}"); - - Optional profile = profilesManager.get(uuid, "someversion"); - - assertTrue(profile.isPresent()); - assertEquals(profile.get().getName(), "somename"); - assertEquals(profile.get().getAvatar(), "someavatar"); - assertThat(profile.get().getCommitment()).isEqualTo("somecommitment".getBytes()); - - verify(commands, times(1)).hget(eq("profiles::" + uuid), eq("someversion")); - verifyNoMoreInteractions(commands); - verifyNoMoreInteractions(profiles); - } - - @Test - public void testGetProfileNotInCache() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profile = new VersionedProfile("someversion", "somename", "someavatar", null, null, - null, "somecommitment".getBytes()); - - when(commands.hget(eq("profiles::" + uuid), eq("someversion"))).thenReturn(null); - when(profiles.get(eq(uuid), eq("someversion"))).thenReturn(Optional.of(profile)); - - Optional retrieved = profilesManager.get(uuid, "someversion"); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), profile); - - verify(commands, times(1)).hget(eq("profiles::" + uuid), eq("someversion")); - verify(commands, times(1)).hset(eq("profiles::" + uuid), eq("someversion"), anyString()); - verifyNoMoreInteractions(commands); - - verify(profiles, times(1)).get(eq(uuid), eq("someversion")); - verifyNoMoreInteractions(profiles); - } - - @Test - public void testGetProfileBrokenCache() { - UUID uuid = UUID.randomUUID(); - VersionedProfile profile = new VersionedProfile("someversion", "somename", "someavatar", null, null, - null, "somecommitment".getBytes()); - - when(commands.hget(eq("profiles::" + uuid), eq("someversion"))).thenThrow(new RedisException("Connection lost")); - when(profiles.get(eq(uuid), eq("someversion"))).thenReturn(Optional.of(profile)); - - Optional retrieved = profilesManager.get(uuid, "someversion"); - - assertTrue(retrieved.isPresent()); - assertSame(retrieved.get(), profile); - - verify(commands, times(1)).hget(eq("profiles::" + uuid), eq("someversion")); - verify(commands, times(1)).hset(eq("profiles::" + uuid), eq("someversion"), anyString()); - verifyNoMoreInteractions(commands); - - verify(profiles, times(1)).get(eq(uuid), eq("someversion")); - verifyNoMoreInteractions(profiles); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/PushFeedbackProcessorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/PushFeedbackProcessorTest.java deleted file mode 100644 index 131fc30e8..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/PushFeedbackProcessorTest.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.storage; - -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.anyBoolean; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.tests.util.AccountsHelper.eqUuid; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountDatabaseCrawlerRestartException; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.PushFeedbackProcessor; -import org.whispersystems.textsecuregcm.tests.util.AccountsHelper; -import org.whispersystems.textsecuregcm.util.Util; - -class PushFeedbackProcessorTest { - - private AccountsManager accountsManager = mock(AccountsManager.class); - - private Account uninstalledAccount = mock(Account.class); - private Account mixedAccount = mock(Account.class); - private Account freshAccount = mock(Account.class); - private Account cleanAccount = mock(Account.class); - private Account stillActiveAccount = mock(Account.class); - - private Device uninstalledDevice = mock(Device.class); - private Device uninstalledDeviceTwo = mock(Device.class); - private Device installedDevice = mock(Device.class); - private Device installedDeviceTwo = mock(Device.class); - private Device recentUninstalledDevice = mock(Device.class); - private Device stillActiveDevice = mock(Device.class); - - @BeforeEach - void setup() { - AccountsHelper.setupMockUpdate(accountsManager); - - when(uninstalledDevice.getUninstalledFeedbackTimestamp()).thenReturn( - Util.todayInMillis() - TimeUnit.DAYS.toMillis(2)); - when(uninstalledDevice.getLastSeen()).thenReturn(Util.todayInMillis() - TimeUnit.DAYS.toMillis(2)); - when(uninstalledDevice.isEnabled()).thenReturn(true); - when(uninstalledDeviceTwo.getUninstalledFeedbackTimestamp()).thenReturn( - Util.todayInMillis() - TimeUnit.DAYS.toMillis(3)); - when(uninstalledDeviceTwo.getLastSeen()).thenReturn(Util.todayInMillis() - TimeUnit.DAYS.toMillis(3)); - when(uninstalledDeviceTwo.isEnabled()).thenReturn(true); - - when(installedDevice.getUninstalledFeedbackTimestamp()).thenReturn(0L); - when(installedDevice.isEnabled()).thenReturn(true); - when(installedDeviceTwo.getUninstalledFeedbackTimestamp()).thenReturn(0L); - when(installedDeviceTwo.isEnabled()).thenReturn(true); - - when(recentUninstalledDevice.getUninstalledFeedbackTimestamp()).thenReturn( - Util.todayInMillis() - TimeUnit.DAYS.toMillis(1)); - when(recentUninstalledDevice.getLastSeen()).thenReturn(Util.todayInMillis()); - when(recentUninstalledDevice.isEnabled()).thenReturn(true); - - when(stillActiveDevice.getUninstalledFeedbackTimestamp()).thenReturn( - Util.todayInMillis() - TimeUnit.DAYS.toMillis(2)); - when(stillActiveDevice.getLastSeen()).thenReturn(Util.todayInMillis()); - when(stillActiveDevice.isEnabled()).thenReturn(true); - - when(uninstalledAccount.getDevices()).thenReturn(List.of(uninstalledDevice)); - when(mixedAccount.getDevices()).thenReturn(List.of(installedDevice, uninstalledDeviceTwo)); - when(freshAccount.getDevices()).thenReturn(List.of(recentUninstalledDevice)); - when(cleanAccount.getDevices()).thenReturn(List.of(installedDeviceTwo)); - when(stillActiveAccount.getDevices()).thenReturn(List.of(stillActiveDevice)); - - when(mixedAccount.getUuid()).thenReturn(UUID.randomUUID()); - when(freshAccount.getUuid()).thenReturn(UUID.randomUUID()); - when(cleanAccount.getUuid()).thenReturn(UUID.randomUUID()); - when(stillActiveAccount.getUuid()).thenReturn(UUID.randomUUID()); - - when(uninstalledAccount.isEnabled()).thenReturn(true); - when(uninstalledAccount.isDiscoverableByPhoneNumber()).thenReturn(true); - when(uninstalledAccount.getUuid()).thenReturn(UUID.randomUUID()); - when(uninstalledAccount.getNumber()).thenReturn("+18005551234"); - - AccountsHelper.setupMockGet(accountsManager, - Set.of(uninstalledAccount, mixedAccount, freshAccount, cleanAccount, stillActiveAccount)); - } - - - @Test - void testEmpty() throws AccountDatabaseCrawlerRestartException { - PushFeedbackProcessor processor = new PushFeedbackProcessor(accountsManager); - processor.timeAndProcessCrawlChunk(Optional.of(UUID.randomUUID()), Collections.emptyList()); - - verifyNoInteractions(accountsManager); - } - - @Test - void testUpdate() throws AccountDatabaseCrawlerRestartException { - PushFeedbackProcessor processor = new PushFeedbackProcessor(accountsManager); - processor.timeAndProcessCrawlChunk(Optional.of(UUID.randomUUID()), - List.of(uninstalledAccount, mixedAccount, stillActiveAccount, freshAccount, cleanAccount)); - - verify(uninstalledDevice).setApnId(isNull()); - verify(uninstalledDevice).setGcmId(isNull()); - verify(uninstalledDevice).setFetchesMessages(eq(false)); - when(uninstalledDevice.isEnabled()).thenReturn(false); - - verify(accountsManager).update(eqUuid(uninstalledAccount), any()); - - verify(uninstalledDeviceTwo).setApnId(isNull()); - verify(uninstalledDeviceTwo).setGcmId(isNull()); - verify(uninstalledDeviceTwo).setFetchesMessages(eq(false)); - when(uninstalledDeviceTwo.isEnabled()).thenReturn(false); - - verify(installedDevice, never()).setApnId(any()); - verify(installedDevice, never()).setGcmId(any()); - verify(installedDevice, never()).setFetchesMessages(anyBoolean()); - - verify(accountsManager).update(eqUuid(mixedAccount), any()); - - verify(recentUninstalledDevice, never()).setApnId(any()); - verify(recentUninstalledDevice, never()).setGcmId(any()); - verify(recentUninstalledDevice, never()).setFetchesMessages(anyBoolean()); - - verify(accountsManager, never()).update(eqUuid(freshAccount), any()); - - verify(installedDeviceTwo, never()).setApnId(any()); - verify(installedDeviceTwo, never()).setGcmId(any()); - verify(installedDeviceTwo, never()).setFetchesMessages(anyBoolean()); - - verify(accountsManager, never()).update(eqUuid(cleanAccount), any()); - - verify(stillActiveDevice).setUninstalledFeedbackTimestamp(eq(0L)); - verify(stillActiveDevice, never()).setApnId(any()); - verify(stillActiveDevice, never()).setGcmId(any()); - verify(stillActiveDevice, never()).setFetchesMessages(anyBoolean()); - when(stillActiveDevice.getUninstalledFeedbackTimestamp()).thenReturn(0L); - - verify(accountsManager).update(eqUuid(stillActiveAccount), any()); - - // there are un-verified calls to updateDevice - clearInvocations(accountsManager); - - // a second crawl should not make any further updates - processor.timeAndProcessCrawlChunk(Optional.of(UUID.randomUUID()), - List.of(uninstalledAccount, mixedAccount, stillActiveAccount, freshAccount, cleanAccount)); - - verify(accountsManager, never()).update(any(Account.class), any()); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java deleted file mode 100644 index 3d737f5dc..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/storage/RedeemedReceiptsManagerTest.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.storage; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.security.SecureRandom; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; -import org.whispersystems.textsecuregcm.storage.DynamoDbExtension; -import org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager; -import org.whispersystems.textsecuregcm.tests.util.AuthHelper; -import org.whispersystems.textsecuregcm.util.TestClock; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -class RedeemedReceiptsManagerTest { - - private static final long NOW_EPOCH_SECONDS = 1_500_000_000L; - private static final String REDEEMED_RECEIPTS_TABLE_NAME = "redeemed_receipts"; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder() - .tableName(REDEEMED_RECEIPTS_TABLE_NAME) - .hashKey(RedeemedReceiptsManager.KEY_SERIAL) - .attributeDefinition(AttributeDefinition.builder() - .attributeName(RedeemedReceiptsManager.KEY_SERIAL) - .attributeType(ScalarAttributeType.B) - .build()) - .build(); - - Clock clock = TestClock.pinned(Instant.ofEpochSecond(NOW_EPOCH_SECONDS)); - ReceiptSerial receiptSerial; - RedeemedReceiptsManager redeemedReceiptsManager; - - @BeforeEach - void beforeEach() throws InvalidInputException { - byte[] receiptSerialBytes = new byte[ReceiptSerial.SIZE]; - SECURE_RANDOM.nextBytes(receiptSerialBytes); - receiptSerial = new ReceiptSerial(receiptSerialBytes); - redeemedReceiptsManager = new RedeemedReceiptsManager( - clock, REDEEMED_RECEIPTS_TABLE_NAME, dynamoDbExtension.getDynamoDbAsyncClient(), Duration.ofDays(90)); - } - - @Test - void testPut() throws ExecutionException, InterruptedException { - final long receiptExpiration = 42; - final long receiptLevel = 3; - CompletableFuture put; - - // initial insert should return true - put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID); - assertThat(put.get()).isTrue(); - - // subsequent attempted inserts with modified parameters should return false - put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration + 1, receiptLevel, AuthHelper.VALID_UUID); - assertThat(put.get()).isFalse(); - put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel + 1, AuthHelper.VALID_UUID); - assertThat(put.get()).isFalse(); - put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID_TWO); - assertThat(put.get()).isFalse(); - - // repeated insert attempt of the original parameters should return true - put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID); - assertThat(put.get()).isTrue(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java deleted file mode 100644 index 94fe45301..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockingDetails; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.util.List; -import java.util.Set; -import java.util.UUID; -import java.util.function.Consumer; -import org.mockito.MockingDetails; -import org.mockito.stubbing.Stubbing; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -public class AccountsHelper { - - public static Account generateTestAccount(String number, List devices) { - return generateTestAccount(number, UUID.randomUUID(), UUID.randomUUID(), devices, null); - } - - public static Account generateTestAccount(String number, UUID uuid, final UUID phoneNumberIdentifier, List devices, byte[] unidentifiedAccessKey) { - final Account account = new Account(); - account.setNumber(number, phoneNumberIdentifier); - account.setUuid(uuid); - devices.forEach(account::addDevice); - account.setUnidentifiedAccessKey(unidentifiedAccessKey); - - return account; - } - - public static void setupMockUpdate(final AccountsManager mockAccountsManager) { - setupMockUpdate(mockAccountsManager, true); - } - - /** - * Only for use by {@link AuthHelper} - */ - public static void setupMockUpdateForAuthHelper(final AccountsManager mockAccountsManager) { - setupMockUpdate(mockAccountsManager, false); - } - - private static void setupMockUpdate(final AccountsManager mockAccountsManager, final boolean markStale) { - when(mockAccountsManager.update(any(), any())).thenAnswer(answer -> { - final Account account = answer.getArgument(0, Account.class); - answer.getArgument(1, Consumer.class).accept(account); - - return markStale ? copyAndMarkStale(account) : account; - }); - - when(mockAccountsManager.updateDevice(any(), anyLong(), any())).thenAnswer(answer -> { - final Account account = answer.getArgument(0, Account.class); - final Long deviceId = answer.getArgument(1, Long.class); - account.getDevice(deviceId).ifPresent(answer.getArgument(2, Consumer.class)); - - return markStale ? copyAndMarkStale(account) : account; - }); - - when(mockAccountsManager.updateDeviceLastSeen(any(), any(), anyLong())).thenAnswer(answer -> { - answer.getArgument(1, Device.class).setLastSeen(answer.getArgument(2, Long.class)); - return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {}); - }); - - when(mockAccountsManager.updateDeviceAuthentication(any(), any(), any())).thenAnswer(answer -> { - answer.getArgument(1, Device.class).setAuthTokenHash(answer.getArgument(2, SaltedTokenHash.class)); - return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {}); - }); - } - - public static void setupMockGet(final AccountsManager mockAccountsManager, final Set mockAccounts) { - when(mockAccountsManager.getByAccountIdentifier(any(UUID.class))).thenAnswer(answer -> { - - final UUID uuid = answer.getArgument(0, UUID.class); - - return mockAccounts.stream() - .filter(account -> uuid.equals(account.getUuid())) - .findFirst() - .map(account -> { - try { - return copyAndMarkStale(account); - } catch (final Exception e) { - throw new RuntimeException(e); - } - }); - }); - } - - private static Account copyAndMarkStale(Account account) throws IOException { - MockingDetails mockingDetails = mockingDetails(account); - - final Account updatedAccount; - if (mockingDetails.isMock()) { - - updatedAccount = mock(Account.class); - - // it’s not possible to make `account` behave as if it were stale, because we use static mocks in AuthHelper - - for (Stubbing stubbing : mockingDetails.getStubbings()) { - switch (stubbing.getInvocation().getMethod().getName()) { - case "getUuid" -> when(updatedAccount.getUuid()).thenAnswer(stubbing); - case "getPhoneNumberIdentifier" -> when(updatedAccount.getPhoneNumberIdentifier()).thenAnswer(stubbing); - case "getNumber" -> when(updatedAccount.getNumber()).thenAnswer(stubbing); - case "getUsername" -> when(updatedAccount.getUsernameHash()).thenAnswer(stubbing); - case "getDevices" -> when(updatedAccount.getDevices()).thenAnswer(stubbing); - case "getDevice" -> when(updatedAccount.getDevice(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing); - case "getMasterDevice" -> when(updatedAccount.getMasterDevice()).thenAnswer(stubbing); - case "isEnabled" -> when(updatedAccount.isEnabled()).thenAnswer(stubbing); - case "isDiscoverableByPhoneNumber" -> when(updatedAccount.isDiscoverableByPhoneNumber()).thenAnswer(stubbing); - case "getNextDeviceId" -> when(updatedAccount.getNextDeviceId()).thenAnswer(stubbing); - case "isSenderKeySupported" -> when(updatedAccount.isSenderKeySupported()).thenAnswer(stubbing); - case "isAnnouncementGroupSupported" -> when(updatedAccount.isAnnouncementGroupSupported()).thenAnswer(stubbing); - case "isChangeNumberSupported" -> when(updatedAccount.isChangeNumberSupported()).thenAnswer(stubbing); - case "isPniSupported" -> when(updatedAccount.isPniSupported()).thenAnswer(stubbing); - case "isStoriesSupported" -> when(updatedAccount.isStoriesSupported()).thenAnswer(stubbing); - case "isGiftBadgesSupported" -> when(updatedAccount.isGiftBadgesSupported()).thenAnswer(stubbing); - case "isPaymentActivationSupported" -> when(updatedAccount.isPaymentActivationSupported()).thenAnswer(stubbing); - case "getEnabledDeviceCount" -> when(updatedAccount.getEnabledDeviceCount()).thenAnswer(stubbing); - case "getRegistrationLock" -> when(updatedAccount.getRegistrationLock()).thenAnswer(stubbing); - case "getIdentityKey" -> when(updatedAccount.getIdentityKey()).thenAnswer(stubbing); - case "getBadges" -> when(updatedAccount.getBadges()).thenAnswer(stubbing); - case "getLastSeen" -> when(updatedAccount.getLastSeen()).thenAnswer(stubbing); - default -> throw new IllegalArgumentException("unsupported method: Account#" + stubbing.getInvocation().getMethod().getName()); - } - } - - } else { - final ObjectMapper mapper = SystemMapper.getMapper(); - updatedAccount = mapper.readValue(mapper.writeValueAsBytes(account), Account.class); - updatedAccount.setNumber(account.getNumber(), account.getPhoneNumberIdentifier()); - account.markStale(); - } - - return updatedAccount; - } - - public static Account eqUuid(Account value) { - return argThat(other -> other.getUuid().equals(value.getUuid())); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java deleted file mode 100644 index 5f174daf4..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Copyright 2013 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.when; - -import com.google.common.collect.ImmutableMap; -import io.dropwizard.auth.AuthFilter; -import io.dropwizard.auth.PolymorphicAuthDynamicFeature; -import io.dropwizard.auth.basic.BasicCredentialAuthFilter; -import io.dropwizard.auth.basic.BasicCredentials; -import java.security.Principal; -import java.util.Base64; -import java.util.Optional; -import java.util.Random; -import java.util.UUID; -import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAccountAuthenticator; -import org.whispersystems.textsecuregcm.auth.DisabledPermittedAuthenticatedAccount; -import org.whispersystems.textsecuregcm.auth.SaltedTokenHash; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.HeaderUtils; - -public class AuthHelper { - // Static seed to ensure reproducible tests. - private static final Random random = new Random(0xf744df3b43a3339cL); - - public static final TestAccount[] TEST_ACCOUNTS = generateTestAccounts(); - - public static final String VALID_NUMBER = "+14150000000"; - public static final UUID VALID_UUID = UUID.randomUUID(); - public static final UUID VALID_PNI = UUID.randomUUID(); - public static final String VALID_PASSWORD = "foo"; - - public static final String VALID_NUMBER_TWO = "+201511111110"; - public static final UUID VALID_UUID_TWO = UUID.randomUUID(); - public static final UUID VALID_PNI_TWO = UUID.randomUUID(); - public static final String VALID_PASSWORD_TWO = "baz"; - - public static final String VALID_NUMBER_3 = "+14445556666"; - public static final UUID VALID_UUID_3 = UUID.randomUUID(); - public static final UUID VALID_PNI_3 = UUID.randomUUID(); - public static final String VALID_PASSWORD_3_PRIMARY = "3primary"; - public static final String VALID_PASSWORD_3_LINKED = "3linked"; - - public static final UUID INVALID_UUID = UUID.randomUUID(); - public static final String INVALID_PASSWORD = "bar"; - - public static final String DISABLED_NUMBER = "+78888888"; - public static final UUID DISABLED_UUID = UUID.randomUUID(); - public static final String DISABLED_PASSWORD = "poof"; - - public static final String UNDISCOVERABLE_NUMBER = "+18005551234"; - public static final UUID UNDISCOVERABLE_UUID = UUID.randomUUID(); - public static final String UNDISCOVERABLE_PASSWORD = "IT'S A SECRET TO EVERYBODY."; - - public static final String VALID_IDENTITY = "BcxxDU9FGMda70E7+Uvm7pnQcEdXQ64aJCpPUeRSfcFo"; - - public static AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class); - public static Account VALID_ACCOUNT = mock(Account.class ); - public static Account VALID_ACCOUNT_TWO = mock(Account.class ); - public static Account DISABLED_ACCOUNT = mock(Account.class ); - public static Account UNDISCOVERABLE_ACCOUNT = mock(Account.class ); - public static Account VALID_ACCOUNT_3 = mock(Account.class ); - - public static Device VALID_DEVICE = mock(Device.class); - public static Device VALID_DEVICE_TWO = mock(Device.class); - public static Device DISABLED_DEVICE = mock(Device.class); - public static Device UNDISCOVERABLE_DEVICE = mock(Device.class); - public static Device VALID_DEVICE_3_PRIMARY = mock(Device.class); - public static Device VALID_DEVICE_3_LINKED = mock(Device.class); - - private static SaltedTokenHash VALID_CREDENTIALS = mock(SaltedTokenHash.class); - private static SaltedTokenHash VALID_CREDENTIALS_TWO = mock(SaltedTokenHash.class); - private static SaltedTokenHash VALID_CREDENTIALS_3_PRIMARY = mock(SaltedTokenHash.class); - private static SaltedTokenHash VALID_CREDENTIALS_3_LINKED = mock(SaltedTokenHash.class); - private static SaltedTokenHash DISABLED_CREDENTIALS = mock(SaltedTokenHash.class); - private static SaltedTokenHash UNDISCOVERABLE_CREDENTIALS = mock(SaltedTokenHash.class); - - public static PolymorphicAuthDynamicFeature getAuthFilter() { - when(VALID_CREDENTIALS.verify("foo")).thenReturn(true); - when(VALID_CREDENTIALS_TWO.verify("baz")).thenReturn(true); - when(VALID_CREDENTIALS_3_PRIMARY.verify(VALID_PASSWORD_3_PRIMARY)).thenReturn(true); - when(VALID_CREDENTIALS_3_LINKED.verify(VALID_PASSWORD_3_LINKED)).thenReturn(true); - when(DISABLED_CREDENTIALS.verify(DISABLED_PASSWORD)).thenReturn(true); - when(UNDISCOVERABLE_CREDENTIALS.verify(UNDISCOVERABLE_PASSWORD)).thenReturn(true); - - when(VALID_DEVICE.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS); - when(VALID_DEVICE_TWO.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS_TWO); - when(VALID_DEVICE_3_PRIMARY.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS_3_PRIMARY); - when(VALID_DEVICE_3_LINKED.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS_3_LINKED); - when(DISABLED_DEVICE.getAuthTokenHash()).thenReturn(DISABLED_CREDENTIALS); - when(UNDISCOVERABLE_DEVICE.getAuthTokenHash()).thenReturn(UNDISCOVERABLE_CREDENTIALS); - - when(VALID_DEVICE.isMaster()).thenReturn(true); - when(VALID_DEVICE_TWO.isMaster()).thenReturn(true); - when(DISABLED_DEVICE.isMaster()).thenReturn(true); - when(UNDISCOVERABLE_DEVICE.isMaster()).thenReturn(true); - when(VALID_DEVICE_3_PRIMARY.isMaster()).thenReturn(true); - when(VALID_DEVICE_3_LINKED.isMaster()).thenReturn(false); - - when(VALID_DEVICE.getId()).thenReturn(1L); - when(VALID_DEVICE_TWO.getId()).thenReturn(1L); - when(DISABLED_DEVICE.getId()).thenReturn(1L); - when(UNDISCOVERABLE_DEVICE.getId()).thenReturn(1L); - when(VALID_DEVICE_3_PRIMARY.getId()).thenReturn(1L); - when(VALID_DEVICE_3_LINKED.getId()).thenReturn(2L); - - when(VALID_DEVICE.isEnabled()).thenReturn(true); - when(VALID_DEVICE_TWO.isEnabled()).thenReturn(true); - when(DISABLED_DEVICE.isEnabled()).thenReturn(false); - when(UNDISCOVERABLE_DEVICE.isMaster()).thenReturn(true); - when(VALID_DEVICE_3_PRIMARY.isEnabled()).thenReturn(true); - when(VALID_DEVICE_3_LINKED.isEnabled()).thenReturn(true); - - when(VALID_ACCOUNT.getDevice(1L)).thenReturn(Optional.of(VALID_DEVICE)); - when(VALID_ACCOUNT.getMasterDevice()).thenReturn(Optional.of(VALID_DEVICE)); - when(VALID_ACCOUNT_TWO.getDevice(eq(1L))).thenReturn(Optional.of(VALID_DEVICE_TWO)); - when(VALID_ACCOUNT_TWO.getMasterDevice()).thenReturn(Optional.of(VALID_DEVICE_TWO)); - when(DISABLED_ACCOUNT.getDevice(eq(1L))).thenReturn(Optional.of(DISABLED_DEVICE)); - when(DISABLED_ACCOUNT.getMasterDevice()).thenReturn(Optional.of(DISABLED_DEVICE)); - when(UNDISCOVERABLE_ACCOUNT.getDevice(eq(1L))).thenReturn(Optional.of(UNDISCOVERABLE_DEVICE)); - when(UNDISCOVERABLE_ACCOUNT.getMasterDevice()).thenReturn(Optional.of(UNDISCOVERABLE_DEVICE)); - when(VALID_ACCOUNT_3.getDevice(1L)).thenReturn(Optional.of(VALID_DEVICE_3_PRIMARY)); - when(VALID_ACCOUNT_3.getMasterDevice()).thenReturn(Optional.of(VALID_DEVICE_3_PRIMARY)); - when(VALID_ACCOUNT_3.getDevice(2L)).thenReturn(Optional.of(VALID_DEVICE_3_LINKED)); - - when(VALID_ACCOUNT_TWO.getEnabledDeviceCount()).thenReturn(6); - - when(VALID_ACCOUNT.getNumber()).thenReturn(VALID_NUMBER); - when(VALID_ACCOUNT.getUuid()).thenReturn(VALID_UUID); - when(VALID_ACCOUNT.getPhoneNumberIdentifier()).thenReturn(VALID_PNI); - when(VALID_ACCOUNT_TWO.getNumber()).thenReturn(VALID_NUMBER_TWO); - when(VALID_ACCOUNT_TWO.getUuid()).thenReturn(VALID_UUID_TWO); - when(VALID_ACCOUNT_TWO.getPhoneNumberIdentifier()).thenReturn(VALID_PNI_TWO); - when(DISABLED_ACCOUNT.getNumber()).thenReturn(DISABLED_NUMBER); - when(DISABLED_ACCOUNT.getUuid()).thenReturn(DISABLED_UUID); - when(UNDISCOVERABLE_ACCOUNT.getNumber()).thenReturn(UNDISCOVERABLE_NUMBER); - when(UNDISCOVERABLE_ACCOUNT.getUuid()).thenReturn(UNDISCOVERABLE_UUID); - when(VALID_ACCOUNT_3.getNumber()).thenReturn(VALID_NUMBER_3); - when(VALID_ACCOUNT_3.getUuid()).thenReturn(VALID_UUID_3); - when(VALID_ACCOUNT_3.getPhoneNumberIdentifier()).thenReturn(VALID_PNI_3); - - when(VALID_ACCOUNT.isEnabled()).thenReturn(true); - when(VALID_ACCOUNT_TWO.isEnabled()).thenReturn(true); - when(DISABLED_ACCOUNT.isEnabled()).thenReturn(false); - when(UNDISCOVERABLE_ACCOUNT.isEnabled()).thenReturn(true); - when(VALID_ACCOUNT_3.isEnabled()).thenReturn(true); - - when(VALID_ACCOUNT.isDiscoverableByPhoneNumber()).thenReturn(true); - when(VALID_ACCOUNT_TWO.isDiscoverableByPhoneNumber()).thenReturn(true); - when(DISABLED_ACCOUNT.isDiscoverableByPhoneNumber()).thenReturn(true); - when(UNDISCOVERABLE_ACCOUNT.isDiscoverableByPhoneNumber()).thenReturn(false); - when(VALID_ACCOUNT_3.isDiscoverableByPhoneNumber()).thenReturn(true); - - when(VALID_ACCOUNT.getIdentityKey()).thenReturn(VALID_IDENTITY); - - reset(ACCOUNTS_MANAGER); - - when(ACCOUNTS_MANAGER.getByE164(VALID_NUMBER)).thenReturn(Optional.of(VALID_ACCOUNT)); - when(ACCOUNTS_MANAGER.getByAccountIdentifier(VALID_UUID)).thenReturn(Optional.of(VALID_ACCOUNT)); - when(ACCOUNTS_MANAGER.getByPhoneNumberIdentifier(VALID_PNI)).thenReturn(Optional.of(VALID_ACCOUNT)); - - when(ACCOUNTS_MANAGER.getByE164(VALID_NUMBER_TWO)).thenReturn(Optional.of(VALID_ACCOUNT_TWO)); - when(ACCOUNTS_MANAGER.getByAccountIdentifier(VALID_UUID_TWO)).thenReturn(Optional.of(VALID_ACCOUNT_TWO)); - when(ACCOUNTS_MANAGER.getByPhoneNumberIdentifier(VALID_PNI_TWO)).thenReturn(Optional.of(VALID_ACCOUNT_TWO)); - - when(ACCOUNTS_MANAGER.getByE164(DISABLED_NUMBER)).thenReturn(Optional.of(DISABLED_ACCOUNT)); - when(ACCOUNTS_MANAGER.getByAccountIdentifier(DISABLED_UUID)).thenReturn(Optional.of(DISABLED_ACCOUNT)); - - when(ACCOUNTS_MANAGER.getByE164(UNDISCOVERABLE_NUMBER)).thenReturn(Optional.of(UNDISCOVERABLE_ACCOUNT)); - when(ACCOUNTS_MANAGER.getByAccountIdentifier(UNDISCOVERABLE_UUID)).thenReturn(Optional.of(UNDISCOVERABLE_ACCOUNT)); - - when(ACCOUNTS_MANAGER.getByE164(VALID_NUMBER_3)).thenReturn(Optional.of(VALID_ACCOUNT_3)); - when(ACCOUNTS_MANAGER.getByAccountIdentifier(VALID_UUID_3)).thenReturn(Optional.of(VALID_ACCOUNT_3)); - when(ACCOUNTS_MANAGER.getByPhoneNumberIdentifier(VALID_PNI_3)).thenReturn(Optional.of(VALID_ACCOUNT_3)); - - AccountsHelper.setupMockUpdateForAuthHelper(ACCOUNTS_MANAGER); - - for (TestAccount testAccount : TEST_ACCOUNTS) { - testAccount.setup(ACCOUNTS_MANAGER); - } - - AuthFilter accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator( - new AccountAuthenticator(ACCOUNTS_MANAGER)).buildAuthFilter(); - AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator( - new DisabledPermittedAccountAuthenticator(ACCOUNTS_MANAGER)).buildAuthFilter(); - - return new PolymorphicAuthDynamicFeature<>(ImmutableMap.of(AuthenticatedAccount.class, accountAuthFilter, - DisabledPermittedAuthenticatedAccount.class, disabledPermittedAccountAuthFilter)); - } - - public static String getAuthHeader(UUID uuid, long deviceId, String password) { - return HeaderUtils.basicAuthHeader(uuid.toString() + "." + deviceId, password); - } - - public static String getAuthHeader(UUID uuid, String password) { - return HeaderUtils.basicAuthHeader(uuid.toString(), password); - } - - public static String getProvisioningAuthHeader(String number, String password) { - return HeaderUtils.basicAuthHeader(number, password); - } - - public static String getUnidentifiedAccessHeader(byte[] key) { - return Base64.getEncoder().encodeToString(key); - } - - public static UUID getRandomUUID(Random random) { - long mostSignificantBits = random.nextLong(); - long leastSignificantBits = random.nextLong(); - mostSignificantBits &= 0xffffffffffff0fffL; - mostSignificantBits |= 0x0000000000004000L; - leastSignificantBits &= 0x3fffffffffffffffL; - leastSignificantBits |= 0x8000000000000000L; - return new UUID(mostSignificantBits, leastSignificantBits); - } - - public static final class TestAccount { - public final String number; - public final UUID uuid; - public final String password; - public final Account account = mock(Account.class); - public final Device device = mock(Device.class); - public final SaltedTokenHash saltedTokenHash = mock(SaltedTokenHash.class); - - public TestAccount(String number, UUID uuid, String password) { - this.number = number; - this.uuid = uuid; - this.password = password; - } - - public String getAuthHeader() { - return AuthHelper.getAuthHeader(uuid, password); - } - - private void setup(final AccountsManager accountsManager) { - when(saltedTokenHash.verify(password)).thenReturn(true); - when(device.getAuthTokenHash()).thenReturn(saltedTokenHash); - when(device.isMaster()).thenReturn(true); - when(device.getId()).thenReturn(1L); - when(device.isEnabled()).thenReturn(true); - when(account.getDevice(1L)).thenReturn(Optional.of(device)); - when(account.getMasterDevice()).thenReturn(Optional.of(device)); - when(account.getNumber()).thenReturn(number); - when(account.getUuid()).thenReturn(uuid); - when(account.isEnabled()).thenReturn(true); - when(accountsManager.getByE164(number)).thenReturn(Optional.of(account)); - when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account)); - } - } - - private static TestAccount[] generateTestAccounts() { - final TestAccount[] testAccounts = new TestAccount[20]; - final long numberBase = 1_409_000_0000L; - for (int i = 0; i < testAccounts.length; i++) { - long currentNumber = numberBase + i; - testAccounts[i] = new TestAccount("+" + currentNumber, getRandomUUID(random), "TestAccountPassword-" + currentNumber); - } - return testAccounts; - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/DevicesHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/DevicesHelper.java deleted file mode 100644 index 0c40a87a5..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/DevicesHelper.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import java.util.Random; -import org.whispersystems.textsecuregcm.entities.SignedPreKey; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.util.Util; - -public class DevicesHelper { - - private static final Random RANDOM = new Random(); - - public static Device createDevice(final long deviceId) { - return createDevice(deviceId, 0); - } - - public static Device createDevice(final long deviceId, final long lastSeen) { - final Device device = new Device(); - device.setId(deviceId); - device.setLastSeen(lastSeen); - device.setUserAgent("OWT"); - - setEnabled(device, true); - - return device; - } - - public static void setEnabled(Device device, boolean enabled) { - if (enabled) { - device.setSignedPreKey(new SignedPreKey(RANDOM.nextLong(), "testPublicKey-" + RANDOM.nextLong(), - "testSignature-" + RANDOM.nextLong())); - device.setGcmId("testGcmId" + RANDOM.nextLong()); - device.setLastSeen(Util.todayInMillis()); - } else { - device.setSignedPreKey(null); - } - - // fail fast, to guard against a change to the isEnabled() implementation causing unexpected test behavior - assert enabled == device.isEnabled(); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java deleted file mode 100644 index 3205f2a6f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import static io.dropwizard.testing.FixtureHelpers.fixture; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import org.whispersystems.textsecuregcm.util.SystemMapper; - -public class JsonHelpers { - - private static final ObjectMapper objectMapper = SystemMapper.getMapper(); - - public static String asJson(Object object) throws JsonProcessingException { - return objectMapper.writeValueAsString(object); - } - - public static T fromJson(String value, Class clazz) throws IOException { - return objectMapper.readValue(value, clazz); - } - - public static String jsonFixture(String filename) throws IOException { - return objectMapper.writeValueAsString(objectMapper.readValue(fixture(filename), JsonNode.class)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/LocaleTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/LocaleTest.java deleted file mode 100644 index c428d5821..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/LocaleTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.whispersystems.textsecuregcm.tests.util; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.Collections; -import java.util.List; -import java.util.Locale.LanguageRange; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.util.Util; - -class LocaleTest { - - private static final Set SUPPORTED_LOCALES = Set.of("es", "en", "zh", "zh-HK"); - - @ParameterizedTest - @MethodSource - void testFindBestLocale(@Nullable final String languageRange, @Nullable final String expectedLocale) { - - final List languageRanges = Optional.ofNullable(languageRange) - .map(LanguageRange::parse) - .orElse(Collections.emptyList()); - - assertEquals(Optional.ofNullable(expectedLocale), Util.findBestLocale(languageRanges, SUPPORTED_LOCALES)); - } - - static Stream testFindBestLocale() { - return Stream.of( - // languageRange, expectedLocale - Arguments.of("en-US, fr", "en"), - Arguments.of("es-ES", "es"), - Arguments.of("zh-Hant-HK, zh-HK", "zh"), - // zh-HK is supported, but Locale#lookup truncates from the end, per RFC-4647 - Arguments.of("zh-Hant-HK", "zh"), - Arguments.of("zh-HK", "zh-HK"), - Arguments.of("de", null), - Arguments.of(null, null) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MessageHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MessageHelper.java deleted file mode 100644 index 0ff6e7856..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MessageHelper.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import com.google.protobuf.ByteString; -import java.nio.charset.StandardCharsets; -import java.util.UUID; -import org.whispersystems.textsecuregcm.entities.MessageProtos; - -public class MessageHelper { - - public static MessageProtos.Envelope createMessage(UUID senderUuid, final int senderDeviceId, UUID destinationUuid, - long timestamp, String content) { - return MessageProtos.Envelope.newBuilder() - .setServerGuid(UUID.randomUUID().toString()) - .setType(MessageProtos.Envelope.Type.CIPHERTEXT) - .setTimestamp(timestamp) - .setServerTimestamp(0) - .setSourceUuid(senderUuid.toString()) - .setSourceDevice(senderDeviceId) - .setDestinationUuid(destinationUuid.toString()) - .setContent(ByteString.copyFrom(content.getBytes(StandardCharsets.UTF_8))) - .build(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MessagesDynamoDbExtension.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MessagesDynamoDbExtension.java deleted file mode 100644 index 1aeba95e1..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MessagesDynamoDbExtension.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import org.whispersystems.textsecuregcm.storage.DynamoDbExtension; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.LocalSecondaryIndex; -import software.amazon.awssdk.services.dynamodb.model.Projection; -import software.amazon.awssdk.services.dynamodb.model.ProjectionType; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -public class MessagesDynamoDbExtension { - - public static final String TABLE_NAME = "Signal_Messages_UnitTest"; - - public static DynamoDbExtension build() { - return DynamoDbExtension.builder() - .tableName(TABLE_NAME) - .hashKey("H") - .rangeKey("S") - .attributeDefinition( - AttributeDefinition.builder().attributeName("H").attributeType(ScalarAttributeType.B).build()) - .attributeDefinition( - AttributeDefinition.builder().attributeName("S").attributeType(ScalarAttributeType.B).build()) - .attributeDefinition( - AttributeDefinition.builder().attributeName("U").attributeType(ScalarAttributeType.B).build()) - .localSecondaryIndex(LocalSecondaryIndex.builder().indexName("Message_UUID_Index") - .keySchema(KeySchemaElement.builder().attributeName("H").keyType(KeyType.HASH).build(), - KeySchemaElement.builder().attributeName("U").keyType(KeyType.RANGE).build()) - .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build()) - .build()) - .build(); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/NumberPrefixTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/NumberPrefixTest.java deleted file mode 100644 index 95d77fc4a..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/NumberPrefixTest.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; - -import org.junit.jupiter.api.Test; -import org.whispersystems.textsecuregcm.util.Util; - -class NumberPrefixTest { - - @Test - void testPrefixes() { - assertThat(Util.getNumberPrefix("+14151234567")).isEqualTo("+14151"); - assertThat(Util.getNumberPrefix("+22587654321")).isEqualTo("+2258765"); - assertThat(Util.getNumberPrefix("+298654321")).isEqualTo("+2986543"); - assertThat(Util.getNumberPrefix("+12")).isEqualTo("+12"); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/RedisClusterHelper.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/RedisClusterHelper.java deleted file mode 100644 index 3112b2d48..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/RedisClusterHelper.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; -import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands; -import io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands; -import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; -import java.util.function.Consumer; -import java.util.function.Function; -import org.whispersystems.textsecuregcm.redis.FaultTolerantRedisCluster; - -public class RedisClusterHelper { - - public static RedisClusterHelper.Builder builder() { - return new Builder(); - } - - @SuppressWarnings("unchecked") - private static FaultTolerantRedisCluster buildMockRedisCluster( - final RedisAdvancedClusterCommands stringCommands, - final RedisAdvancedClusterCommands binaryCommands, - final RedisAdvancedClusterAsyncCommands binaryAsyncCommands, - final RedisAdvancedClusterReactiveCommands binaryReactiveCommands) { - final FaultTolerantRedisCluster cluster = mock(FaultTolerantRedisCluster.class); - final StatefulRedisClusterConnection stringConnection = mock(StatefulRedisClusterConnection.class); - final StatefulRedisClusterConnection binaryConnection = mock(StatefulRedisClusterConnection.class); - - when(stringConnection.sync()).thenReturn(stringCommands); - when(binaryConnection.sync()).thenReturn(binaryCommands); - when(binaryConnection.async()).thenReturn(binaryAsyncCommands); - when(binaryConnection.reactive()).thenReturn(binaryReactiveCommands); - - when(cluster.withCluster(any(Function.class))).thenAnswer(invocation -> { - return invocation.getArgument(0, Function.class).apply(stringConnection); - }); - - doAnswer(invocation -> { - invocation.getArgument(0, Consumer.class).accept(stringConnection); - return null; - }).when(cluster).useCluster(any(Consumer.class)); - - when(cluster.withCluster(any(Function.class))).thenAnswer(invocation -> { - return invocation.getArgument(0, Function.class).apply(stringConnection); - }); - - doAnswer(invocation -> { - invocation.getArgument(0, Consumer.class).accept(stringConnection); - return null; - }).when(cluster).useCluster(any(Consumer.class)); - - when(cluster.withBinaryCluster(any(Function.class))).thenAnswer(invocation -> { - return invocation.getArgument(0, Function.class).apply(binaryConnection); - }); - - doAnswer(invocation -> { - invocation.getArgument(0, Consumer.class).accept(binaryConnection); - return null; - }).when(cluster).useBinaryCluster(any(Consumer.class)); - - when(cluster.withBinaryCluster(any(Function.class))).thenAnswer(invocation -> { - return invocation.getArgument(0, Function.class).apply(binaryConnection); - }); - - doAnswer(invocation -> { - invocation.getArgument(0, Consumer.class).accept(binaryConnection); - return null; - }).when(cluster).useBinaryCluster(any(Consumer.class)); - - return cluster; - } - - @SuppressWarnings("unchecked") - public static class Builder { - - private RedisAdvancedClusterCommands stringCommands = mock(RedisAdvancedClusterCommands.class); - private RedisAdvancedClusterCommands binaryCommands = mock(RedisAdvancedClusterCommands.class); - private RedisAdvancedClusterAsyncCommands binaryAsyncCommands = mock( - RedisAdvancedClusterAsyncCommands.class); - private RedisAdvancedClusterReactiveCommands binaryReactiveCommands = mock( - RedisAdvancedClusterReactiveCommands.class); - - private Builder() { - - } - - public Builder stringCommands(final RedisAdvancedClusterCommands stringCommands) { - this.stringCommands = stringCommands; - return this; - } - - public Builder binaryCommands(final RedisAdvancedClusterCommands binaryCommands) { - this.binaryCommands = binaryCommands; - return this; - } - - public Builder binaryAsyncCommands(final RedisAdvancedClusterAsyncCommands binaryAsyncCommands) { - this.binaryAsyncCommands = binaryAsyncCommands; - return this; - } - - public Builder binaryReactiveCommands( - final RedisAdvancedClusterReactiveCommands binaryReactiveCommands) { - this.binaryReactiveCommands = binaryReactiveCommands; - return this; - } - - public FaultTolerantRedisCluster build() { - return RedisClusterHelper.buildMockRedisCluster(stringCommands, binaryCommands, binaryAsyncCommands, - binaryReactiveCommands); - } - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/SynchronousExecutorService.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/SynchronousExecutorService.java deleted file mode 100644 index 1bf95b74a..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/SynchronousExecutorService.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import com.google.common.util.concurrent.SettableFuture; - -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -public class SynchronousExecutorService implements ExecutorService { - - private boolean shutdown = false; - - @Override - public void shutdown() { - shutdown = true; - } - - @Override - public List shutdownNow() { - shutdown = true; - return Collections.emptyList(); - } - - @Override - public boolean isShutdown() { - return shutdown; - } - - @Override - public boolean isTerminated() { - return shutdown; - } - - @Override - public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { - return true; - } - - @Override - public Future submit(Callable task) { - SettableFuture future = null; - try { - future = SettableFuture.create(); - future.set(task.call()); - } catch (Throwable e) { - future.setException(e); - } - - return future; - } - - @Override - public Future submit(Runnable task, T result) { - SettableFuture future = SettableFuture.create(); - task.run(); - - future.set(result); - - return future; - } - - @Override - public Future submit(Runnable task) { - SettableFuture future = SettableFuture.create(); - task.run(); - future.set(null); - return future; - } - - @Override - public List> invokeAll(Collection> tasks) throws InterruptedException { - List> results = new LinkedList<>(); - for (Callable callable : tasks) { - SettableFuture future = SettableFuture.create(); - try { - future.set(callable.call()); - } catch (Throwable e) { - future.setException(e); - } - results.add(future); - } - return results; - } - - @Override - public List> invokeAll(Collection> tasks, long timeout, TimeUnit unit) throws InterruptedException { - return invokeAll(tasks); - } - - @Override - public T invokeAny(Collection> tasks) throws InterruptedException, ExecutionException { - return null; - } - - @Override - public T invokeAny(Collection> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { - return null; - } - - @Override - public void execute(Runnable command) { - command.run(); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ValidNumberTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ValidNumberTest.java deleted file mode 100644 index a1566d9ee..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ValidNumberTest.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.tests.util; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException; -import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException; -import org.whispersystems.textsecuregcm.util.Util; - -class ValidNumberTest { - - @ParameterizedTest - @ValueSource(strings = { - "+447700900111", - "+14151231234", - "+71234567890", - "+447535742222", - "+4915174108888", - "+2250707312345", - "+298123456", - "+299123456", - "+376123456", - "+68512345", - "+689123456", - "+80011111111"}) - void requireNormalizedNumber(final String number) { - assertDoesNotThrow(() -> Util.requireNormalizedNumber(number)); - } - - @Test - void requireNormalizedNumberNull() { - assertThrows(ImpossiblePhoneNumberException.class, () -> Util.requireNormalizedNumber(null)); - } - - @ParameterizedTest - @ValueSource(strings = { - "Definitely not a phone number at all", - "+141512312341", - "+712345678901", - "+4475357422221", - "+491517410888811111", - "71234567890", - "001447535742222", - "+1415123123a" - }) - void requireNormalizedNumberImpossibleNumber(final String number) { - assertThrows(ImpossiblePhoneNumberException.class, () -> Util.requireNormalizedNumber(number)); - } - - @ParameterizedTest - @ValueSource(strings = { - "+4407700900111", - "+49493023125000", // double country code - this e164 is "possible" - "+1 415 123 1234", - "+1 (415) 123-1234", - "+1 415)123-1234", - " +14151231234"}) - void requireNormalizedNumberNonNormalized(final String number) { - assertThrows(NonNormalizedPhoneNumberException.class, () -> Util.requireNormalizedNumber(number)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/AttributeValuesTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/AttributeValuesTest.java deleted file mode 100644 index b9b8115b2..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/AttributeValuesTest.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import org.junit.jupiter.api.Test; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; -import java.nio.ByteBuffer; -import java.util.Map; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -public class AttributeValuesTest { - @Test - void testUUIDRoundTrip() { - UUID orig = UUID.randomUUID(); - AttributeValue av = AttributeValues.fromUUID(orig); - UUID returned = AttributeValues.getUUID(Map.of("foo", av), "foo", null); - assertEquals(orig, returned); - } - - @Test - void testLongRoundTrip() { - long orig = 12345; - AttributeValue av = AttributeValues.fromLong(orig); - long returned = AttributeValues.getLong(Map.of("foo", av), "foo", -1); - assertEquals(orig, returned); - } - - @Test - void testIntRoundTrip() { - int orig = 12345; - AttributeValue av = AttributeValues.fromInt(orig); - int returned = AttributeValues.getInt(Map.of("foo", av), "foo", -1); - assertEquals(orig, returned); - } - - @Test - void testByteBuffer() { - byte[] bytes = {1, 2, 3}; - ByteBuffer bb = ByteBuffer.wrap(bytes); - AttributeValue av = AttributeValues.fromByteBuffer(bb); - byte[] returned = av.b().asByteArray(); - assertArrayEquals(bytes, returned); - returned = AttributeValues.getByteArray(Map.of("foo", av), "foo", null); - assertArrayEquals(bytes, returned); - } - - @Test - void testByteBuffer2() { - final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[8]); - byteBuffer.putLong(123); - assertEquals(byteBuffer.remaining(), 0); - AttributeValue av = AttributeValues.fromByteBuffer(byteBuffer.flip()); - assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 0, 0, 123}, AttributeValues.getByteArray(Map.of("foo", av), "foo", null)); - } - - @Test - void testNullUuid() { - final Map item = Map.of("key", AttributeValue.builder().nul(true).build()); - assertNull(AttributeValues.getUUID(item, "key", null)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/DestinationDeviceValidatorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/DestinationDeviceValidatorTest.java deleted file mode 100644 index 2ad4e7837..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/DestinationDeviceValidatorTest.java +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.params.provider.Arguments.arguments; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.Set; -import java.util.stream.Stream; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException; -import org.whispersystems.textsecuregcm.controllers.StaleDevicesException; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; - -@ExtendWith(DropwizardExtensionsSupport.class) -class DestinationDeviceValidatorTest { - - static Account mockAccountWithDeviceAndRegId(final Map registrationIdsByDeviceId) { - final Account account = mock(Account.class); - - registrationIdsByDeviceId.forEach((deviceId, registrationId) -> { - final Device device = mock(Device.class); - when(device.getRegistrationId()).thenReturn(registrationId); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - }); - - return account; - } - - static Stream validateRegistrationIdsSource() { - return Stream.of( - arguments( - mockAccountWithDeviceAndRegId(Map.of(1L, 0xFFFF, 2L, 0xDEAD, 3L, 0xBEEF)), - Map.of(1L, 0xFFFF, 2L, 0xDEAD, 3L, 0xBEEF), - null), - arguments( - mockAccountWithDeviceAndRegId(Map.of(1L, 42)), - Map.of(1L, 1492), - Set.of(1L)), - arguments( - mockAccountWithDeviceAndRegId(Map.of(1L, 42)), - Map.of(1L, 42), - null), - arguments( - mockAccountWithDeviceAndRegId(Map.of(1L, 42)), - Map.of(1L, 0), - null), - arguments( - mockAccountWithDeviceAndRegId(Map.of(1L, 42, 2L, 255)), - Map.of(1L, 0, 2L, 42), - Set.of(2L)), - arguments( - mockAccountWithDeviceAndRegId(Map.of(1L, 42, 2L, 256)), - Map.of(1L, 41, 2L, 257), - Set.of(1L, 2L)) - ); - } - - @ParameterizedTest - @MethodSource("validateRegistrationIdsSource") - void testValidateRegistrationIds( - Account account, - Map registrationIdsByDeviceId, - Set expectedStaleDeviceIds) throws Exception { - if (expectedStaleDeviceIds != null) { - Assertions.assertThat(assertThrows(StaleDevicesException.class, - () -> DestinationDeviceValidator.validateRegistrationIds( - account, - registrationIdsByDeviceId.entrySet(), - Map.Entry::getKey, - Map.Entry::getValue, - false)) - .getStaleDevices()) - .hasSameElementsAs(expectedStaleDeviceIds); - } else { - DestinationDeviceValidator.validateRegistrationIds(account, registrationIdsByDeviceId.entrySet(), - Map.Entry::getKey, Map.Entry::getValue, false); - } - } - - static Account mockAccountWithDeviceAndEnabled(final Map enabledStateByDeviceId) { - final Account account = mock(Account.class); - final List devices = new ArrayList<>(); - - enabledStateByDeviceId.forEach((deviceId, enabled) -> { - final Device device = mock(Device.class); - when(device.isEnabled()).thenReturn(enabled); - when(device.getId()).thenReturn(deviceId); - when(account.getDevice(deviceId)).thenReturn(Optional.of(device)); - - devices.add(device); - }); - - when(account.getDevices()).thenReturn(devices); - - return account; - } - - static Stream validateCompleteDeviceListSource() { - return Stream.of( - arguments( - mockAccountWithDeviceAndEnabled(Map.of(1L, true, 2L, false, 3L, true)), - Set.of(1L, 3L), - null, - null, - Collections.emptySet()), - arguments( - mockAccountWithDeviceAndEnabled(Map.of(1L, true, 2L, false, 3L, true)), - Set.of(1L, 2L, 3L), - null, - Set.of(2L), - Collections.emptySet()), - arguments( - mockAccountWithDeviceAndEnabled(Map.of(1L, true, 2L, false, 3L, true)), - Set.of(1L), - Set.of(3L), - null, - Collections.emptySet()), - arguments( - mockAccountWithDeviceAndEnabled(Map.of(1L, true, 2L, false, 3L, true)), - Set.of(1L, 2L), - Set.of(3L), - Set.of(2L), - Collections.emptySet()), - arguments( - mockAccountWithDeviceAndEnabled(Map.of(1L, true, 2L, false, 3L, true)), - Set.of(1L), - Set.of(3L), - Set.of(1L), - Set.of(1L) - ), - arguments( - mockAccountWithDeviceAndEnabled(Map.of(1L, true, 2L, false, 3L, true)), - Set.of(2L), - Set.of(3L), - Set.of(2L), - Set.of(1L) - ), - arguments( - mockAccountWithDeviceAndEnabled(Map.of(1L, true, 2L, false, 3L, true)), - Set.of(3L), - null, - null, - Set.of(1L) - ) - ); - } - - @ParameterizedTest - @MethodSource("validateCompleteDeviceListSource") - void testValidateCompleteDeviceList( - Account account, - Set deviceIds, - Collection expectedMissingDeviceIds, - Collection expectedExtraDeviceIds, - Set excludedDeviceIds) throws Exception { - - if (expectedMissingDeviceIds != null || expectedExtraDeviceIds != null) { - final MismatchedDevicesException mismatchedDevicesException = assertThrows(MismatchedDevicesException.class, - () -> DestinationDeviceValidator.validateCompleteDeviceList(account, deviceIds, excludedDeviceIds)); - if (expectedMissingDeviceIds != null) { - Assertions.assertThat(mismatchedDevicesException.getMissingDevices()) - .hasSameElementsAs(expectedMissingDeviceIds); - } - if (expectedExtraDeviceIds != null) { - Assertions.assertThat(mismatchedDevicesException.getExtraDevices()).hasSameElementsAs(expectedExtraDeviceIds); - } - } else { - DestinationDeviceValidator.validateCompleteDeviceList(account, deviceIds, excludedDeviceIds); - } - } - - @Test - void testDuplicateDeviceIds() { - final Account account = mockAccountWithDeviceAndRegId(Map.of(Device.MASTER_ID, 17)); - try { - DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, 16), new Pair<>(Device.MASTER_ID, 17)), false); - Assertions.fail("duplicate devices should throw StaleDevicesException"); - } catch (StaleDevicesException e) { - Assertions.assertThat(e.getStaleDevices()).hasSameElementsAs(Collections.singletonList(Device.MASTER_ID)); - } - } - - @Test - void testValidatePniRegistrationIds() { - final Device device = mock(Device.class); - when(device.getId()).thenReturn(Device.MASTER_ID); - - final Account account = mock(Account.class); - when(account.getDevices()).thenReturn(List.of(device)); - when(account.getDevice(Device.MASTER_ID)).thenReturn(Optional.of(device)); - - final int aciRegistrationId = 17; - final int pniRegistrationId = 89; - final int incorrectRegistrationId = aciRegistrationId + pniRegistrationId; - - when(device.getRegistrationId()).thenReturn(aciRegistrationId); - when(device.getPhoneNumberIdentityRegistrationId()).thenReturn(OptionalInt.of(pniRegistrationId)); - - assertDoesNotThrow( - () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, aciRegistrationId)), false)); - assertDoesNotThrow( - () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, pniRegistrationId)), - true)); - assertThrows(StaleDevicesException.class, - () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, aciRegistrationId)), - true)); - assertThrows(StaleDevicesException.class, - () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, pniRegistrationId)), - false)); - - when(device.getPhoneNumberIdentityRegistrationId()).thenReturn(OptionalInt.empty()); - - assertDoesNotThrow( - () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, aciRegistrationId)), - false)); - assertDoesNotThrow( - () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, aciRegistrationId)), - true)); - assertThrows(StaleDevicesException.class, () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, incorrectRegistrationId)), true)); - assertThrows(StaleDevicesException.class, () -> DestinationDeviceValidator.validateRegistrationIds(account, - Stream.of(new Pair<>(Device.MASTER_ID, incorrectRegistrationId)), false)); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java deleted file mode 100644 index 8901e00df..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.lang.reflect.Method; -import java.util.Set; -import javax.validation.ConstraintViolation; -import javax.validation.Validation; -import javax.validation.Validator; -import org.junit.jupiter.api.Test; - -public class E164Test { - - private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator(); - - private static final String E164_VALID = "+18005550123"; - - private static final String E164_INVALID = "1(800)555-0123"; - - private static final String EMPTY = ""; - - @SuppressWarnings("FieldCanBeLocal") - private static class Data { - - @E164 - private final String number; - - private Data(final String number) { - this.number = number; - } - } - - private static class Methods { - - public void foo(@E164 final String number) { - // noop - } - - @E164 - public String bar() { - return "nevermind"; - } - } - - private record Rec(@E164 String number) { - } - - @Test - public void testRecord() throws Exception { - checkNoViolations(new Rec(E164_VALID)); - checkHasViolations(new Rec(E164_INVALID)); - checkHasViolations(new Rec(EMPTY)); - } - - @Test - public void testClassField() throws Exception { - checkNoViolations(new Data(E164_VALID)); - checkHasViolations(new Data(E164_INVALID)); - checkHasViolations(new Data(EMPTY)); - } - - @Test - public void testParameters() throws Exception { - final Methods m = new Methods(); - final Method foo = Methods.class.getMethod("foo", String.class); - - final Set> violations1 = - VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_VALID}); - final Set> violations2 = - VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_INVALID}); - final Set> violations3 = - VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {EMPTY}); - - assertTrue(violations1.isEmpty()); - assertFalse(violations2.isEmpty()); - assertFalse(violations3.isEmpty()); - } - - @Test - public void testReturnValue() throws Exception { - final Methods m = new Methods(); - final Method bar = Methods.class.getMethod("bar"); - - final Set> violations1 = - VALIDATOR.forExecutables().validateReturnValue(m, bar, E164_VALID); - final Set> violations2 = - VALIDATOR.forExecutables().validateReturnValue(m, bar, E164_INVALID); - final Set> violations3 = - VALIDATOR.forExecutables().validateReturnValue(m, bar, EMPTY); - - assertTrue(violations1.isEmpty()); - assertFalse(violations2.isEmpty()); - assertFalse(violations3.isEmpty()); - } - - private static void checkNoViolations(final T object) { - final Set> violations = VALIDATOR.validate(object); - assertTrue(violations.isEmpty()); - } - - private static void checkHasViolations(final T object) { - final Set> violations = VALIDATOR.validate(object); - assertFalse(violations.isEmpty()); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/HeaderUtilsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/HeaderUtilsTest.java deleted file mode 100644 index 92f2a0b9f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/HeaderUtilsTest.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.params.provider.Arguments.arguments; - -import java.util.Optional; -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class HeaderUtilsTest { - - @SuppressWarnings("OptionalUsedAsFieldOrParameterType") - @ParameterizedTest - @MethodSource("argumentsForGetMostRecentProxy") - void getMostRecentProxy(final String forwardedFor, final Optional expectedMostRecentProxy) { - assertEquals(expectedMostRecentProxy, HeaderUtils.getMostRecentProxy(forwardedFor)); - } - - private static Stream argumentsForGetMostRecentProxy() { - return Stream.of( - arguments(null, Optional.empty()), - arguments("", Optional.empty()), - arguments(" ", Optional.empty()), - arguments("203.0.113.195,", Optional.empty()), - arguments("203.0.113.195, ", Optional.empty()), - arguments("203.0.113.195", Optional.of("203.0.113.195")), - arguments("203.0.113.195, 70.41.3.18, 150.172.238.178", Optional.of("150.172.238.178")) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/MockUtils.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/MockUtils.java deleted file mode 100644 index def0353ea..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/MockUtils.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; - -import java.time.Duration; -import java.util.Optional; -import org.mockito.Mockito; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; -import org.whispersystems.textsecuregcm.limits.RateLimiter; -import org.whispersystems.textsecuregcm.limits.RateLimiters; - -public final class MockUtils { - - private MockUtils() { - // utility class - } - - @FunctionalInterface - public interface MockInitializer { - - void init(T mock) throws Exception; - } - - public static T buildMock(final Class clazz, final MockInitializer initializer) throws RuntimeException { - final T mock = Mockito.mock(clazz); - try { - initializer.init(mock); - } catch (Exception e) { - throw new RuntimeException(e); - } - return mock; - } - - public static MutableClock mutableClock(final long timeMillis) { - return new MutableClock(timeMillis); - } - - public static void updateRateLimiterResponseToAllow( - final RateLimiters rateLimitersMock, - final RateLimiters.Handle handle, - final String input) { - final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class); - doReturn(Optional.of(mockRateLimiter)).when(rateLimitersMock).byHandle(eq(handle)); - try { - doNothing().when(mockRateLimiter).validate(eq(input)); - } catch (final RateLimitExceededException e) { - throw new RuntimeException(e); - } - } - - public static void updateRateLimiterResponseToFail( - final RateLimiters rateLimitersMock, - final RateLimiters.Handle handle, - final String input, - final Duration retryAfter, - final boolean legacyStatusCode) { - final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class); - doReturn(Optional.of(mockRateLimiter)).when(rateLimitersMock).byHandle(eq(handle)); - try { - doThrow(new RateLimitExceededException(retryAfter, legacyStatusCode)).when(mockRateLimiter).validate(eq(input)); - } catch (final RateLimitExceededException e) { - throw new RuntimeException(e); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java deleted file mode 100644 index 5fae8f9ff..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -public class MutableClock extends Clock { - - private final AtomicReference delegate; - - - public MutableClock(final long timeMillis) { - this(fixedTimeMillis(timeMillis)); - } - - public MutableClock(final Clock clock) { - this.delegate = new AtomicReference<>(clock); - } - - public MutableClock() { - this(Clock.systemUTC()); - } - - public MutableClock setTimeMillis(final long timeMillis) { - delegate.set(fixedTimeMillis(timeMillis)); - return this; - } - - public MutableClock incrementMillis(final long incrementMillis) { - return increment(incrementMillis, TimeUnit.MILLISECONDS); - } - - public MutableClock incrementSeconds(final long incrementSeconds) { - return increment(incrementSeconds, TimeUnit.SECONDS); - } - - public MutableClock increment(final long increment, final TimeUnit timeUnit) { - final long current = delegate.get().instant().toEpochMilli(); - delegate.set(fixedTimeMillis(current + timeUnit.toMillis(increment))); - return this; - } - - @Override - public ZoneId getZone() { - return delegate.get().getZone(); - } - - @Override - public Clock withZone(final ZoneId zone) { - return delegate.get().withZone(zone); - } - - @Override - public Instant instant() { - return delegate.get().instant(); - } - - private static Clock fixedTimeMillis(final long timeMillis) { - return Clock.fixed(Instant.ofEpochMilli(timeMillis), ZoneId.of("Etc/UTC")); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/RedisClusterUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/RedisClusterUtilTest.java deleted file mode 100644 index 465d6aac1..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/RedisClusterUtilTest.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import io.lettuce.core.cluster.SlotHash; -import org.junit.jupiter.api.Test; - -class RedisClusterUtilTest { - - @Test - void testGetMinimalHashTag() { - for (int slot = 0; slot < SlotHash.SLOT_COUNT; slot++) { - assertEquals(slot, SlotHash.getSlot(RedisClusterUtil.getMinimalHashTag(slot))); - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/TestClock.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/TestClock.java deleted file mode 100644 index 3d33ab75b..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/TestClock.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.util.Optional; - -/** - * Clock class specialized for testing. - *

- * This clock can be pinned to a particular instant or can provide the "normal" time. - *

- * Unlike normal clocks it can be dynamically pinned and unpinned to help with testing. - * It should not be used in production. - */ -public class TestClock extends Clock { - - private volatile Optional pinnedInstant; - private final ZoneId zoneId; - - private TestClock(Optional maybePinned, ZoneId id) { - this.pinnedInstant = maybePinned; - this.zoneId = id; - } - - /** - * Instantiate a test clock that returns the "real" time. - *

- * The clock can later be pinned to an instant if desired. - * - * @return unpinned test clock. - */ - public static TestClock now() { - return new TestClock(Optional.empty(), ZoneId.of("UTC")); - } - - /** - * Instantiate a test clock pinned to a particular instant. - *

- * The clock can later be pinned to a different instant or unpinned if desired. - *

- * Unlike the fixed constructor no time zone is required (it defaults to UTC). - * - * @param instant the instant to pin the clock to. - * @return test clock pinned to the given instant. - */ - public static TestClock pinned(Instant instant) { - return new TestClock(Optional.of(instant), ZoneId.of("UTC")); - } - - /** - * Pin this test clock to the given instance. - *

- * This modifies the existing clock in-place. - * - * @param instant the instant to pin the clock to. - */ - public void pin(Instant instant) { - this.pinnedInstant = Optional.of(instant); - } - - /** - * Unpin this test clock so it will being returning the "real" time. - *

- * This modifies the existing clock in-place. - */ - public void unpin() { - this.pinnedInstant = Optional.empty(); - } - - - @Override - public TestClock withZone(ZoneId id) { - return new TestClock(pinnedInstant, id); - } - - @Override - public ZoneId getZone() { - return zoneId; - } - - @Override - public Instant instant() { - return pinnedInstant.orElseGet(Instant::now); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/WeightedRandomSelectTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/WeightedRandomSelectTest.java deleted file mode 100644 index 0e992b899..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/WeightedRandomSelectTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.whispersystems.textsecuregcm.util; - -import org.junit.jupiter.api.Test; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WeightedRandomSelectTest { - - @Test - public void test5050() { - final WeightedRandomSelect selector = new WeightedRandomSelect<>( - List.of(new Pair<>("a", 1L), new Pair<>("b", 1L))); - final Map counts = Stream.generate(selector::select) - .limit(1000) - .collect(Collectors.groupingBy(s -> s, Collectors.counting())); - assertThat(counts.get("a")).isGreaterThan(1); - assertThat(counts.get("b")).isGreaterThan(1); - } - - @Test - public void testAlways() { - final WeightedRandomSelect selector = new WeightedRandomSelect<>( - List.of(new Pair<>("a", 1L), new Pair<>("b", 0L))); - final Map counts = Stream.generate(selector::select) - .limit(1000) - .collect(Collectors.groupingBy(s -> s, Collectors.counting())); - assertThat(counts.get("a")).isEqualTo(1000); - assertThat(counts).doesNotContainKey("b"); - } - - @Test - public void testThree() { - final WeightedRandomSelect selector = new WeightedRandomSelect<>( - List.of(new Pair<>("a", 33L), new Pair<>("b", 33L), new Pair<>("c", 33L))); - final Map counts = Stream.generate(selector::select) - .limit(1000) - .collect(Collectors.groupingBy(s -> s, Collectors.counting())); - assertThat(counts.get("a")).isGreaterThan(1); - assertThat(counts.get("b")).isGreaterThan(1); - assertThat(counts.get("c")).isGreaterThan(1); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapperTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapperTest.java deleted file mode 100644 index 37cf4a4f5..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapperTest.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.matches; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.net.HttpHeaders; -import io.dropwizard.jersey.DropwizardResourceConfig; -import io.dropwizard.jersey.jackson.JacksonMessageBodyProvider; -import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; -import io.dropwizard.testing.junit5.ResourceExtension; -import java.security.Principal; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Stream; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.core.Response; -import org.eclipse.jetty.websocket.api.RemoteEndpoint; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.UpgradeRequest; -import org.glassfish.jersey.server.ApplicationHandler; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.slf4j.Logger; -import org.whispersystems.websocket.WebSocketResourceProvider; -import org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider; -import org.whispersystems.websocket.logging.WebsocketRequestLog; -import org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory; -import org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider; - -@ExtendWith(DropwizardExtensionsSupport.class) -class LoggingUnhandledExceptionMapperTest { - - private static final Logger logger = mock(Logger.class); - - private static final LoggingUnhandledExceptionMapper exceptionMapper = spy(new LoggingUnhandledExceptionMapper(logger)); - - private static final ResourceExtension resources = ResourceExtension.builder() - .addProvider(exceptionMapper) - .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new TestController()) - .build(); - - static Stream testExceptionMapper() { - return Stream.of( - Arguments.of(false, "/v1/test/no-exception", "/v1/test/no-exception", "Signal-Android/5.1.2 Android/30", null), - Arguments.of(true, "/v1/test/unhandled-runtime-exception", "/v1/test/unhandled-runtime-exception", "Signal-Android/5.1.2 Android/30", "ANDROID 5.1.2"), - Arguments.of(true, "/v1/test/unhandled-runtime-exception/1/and/two", "/v1/test/unhandled-runtime-exception/\\{parameter1\\}/and/\\{parameter2\\}", "Signal-iOS/5.10.2 iOS/14.1", "IOS 5.10.2"), - Arguments.of(true, "/v1/test/unhandled-runtime-exception", "/v1/test/unhandled-runtime-exception", "Some literal user-agent", "Some literal user-agent") - ); - } - - @AfterEach - void teardown() { - reset(exceptionMapper, logger); - } - - @ParameterizedTest - @MethodSource - void testExceptionMapper(final boolean expectException, final String targetPath, final String loggedPath, - final String userAgentHeader, final String userAgentLog) { - - resources.getJerseyTest() - .target(targetPath) - .request() - .header(HttpHeaders.USER_AGENT, userAgentHeader) - .get(); - - if (expectException) { - verify(exceptionMapper, times(1)).toResponse(any(Exception.class)); - verify(logger, times(1)) - .error(matches(String.format(".* at GET %s \\(%s\\)", loggedPath, userAgentLog)), any(Exception.class)); - - } else { - verifyNoInteractions(exceptionMapper); - } - } - - @ParameterizedTest - @MethodSource("testExceptionMapper") - void testWebsocketExceptionMapper(final boolean expectException, final String targetPath, final String loggedPath, - final String userAgentHeader, final String userAgentLog) { - - Session session = mock(Session.class); - WebSocketResourceProvider provider = createWebsocketProvider(userAgentHeader, session); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory() - .createRequest(Optional.of(111L), "GET", targetPath, new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - if (expectException) { - verify(exceptionMapper, times(1)).toResponse(any(Exception.class)); - verify(logger, times(1)) - .error(matches(String.format(".* at GET %s \\(%s\\)", loggedPath, userAgentLog)), any(Exception.class)); - - } else { - verifyNoInteractions(exceptionMapper); - } - - } - - private WebSocketResourceProvider createWebsocketProvider(final String userAgentHeader, final Session session) { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(exceptionMapper); - resourceConfig.register(new TestController()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("foo"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - when(request.getHeader(HttpHeaders.USER_AGENT)).thenReturn(userAgentHeader); - when(request.getHeaders()).thenReturn(Map.of(HttpHeaders.USER_AGENT, List.of(userAgentHeader))); - - return provider; - } - - @Path("/v1/test") - public static class TestController { - - @GET - @Path("/no-exception") - public Response testNoException() { - return Response.ok().build(); - } - - @GET - @Path("/unhandled-runtime-exception") - public Response testUnhandledException() { - throw new RuntimeException(); - } - - @GET - @Path("/unhandled-runtime-exception/{parameter1}/and/{parameter2}") - public Response testUnhandledExceptionWithPathParameter(@PathParam("parameter1") String parameter1, - @PathParam("parameter2") String parameter2) { - throw new RuntimeException(); - } - } - - public static class TestPrincipal implements Principal { - - private final String name; - - private TestPrincipal(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtilTest.java deleted file mode 100644 index a5800dfa7..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtilTest.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.logging; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import java.util.Arrays; -import org.glassfish.jersey.server.ExtendedUriInfo; -import org.glassfish.jersey.uri.UriTemplate; -import org.junit.jupiter.api.Test; - -class UriInfoUtilTest { - - @Test - void testGetPathTemplate() { - final UriTemplate firstComponent = new UriTemplate("/first"); - final UriTemplate secondComponent = new UriTemplate("/second"); - final UriTemplate thirdComponent = new UriTemplate("/{param}/{moreDifferentParam}"); - - final ExtendedUriInfo uriInfo = mock(ExtendedUriInfo.class); - when(uriInfo.getMatchedTemplates()).thenReturn(Arrays.asList(thirdComponent, secondComponent, firstComponent)); - - assertEquals("/first/second/{param}/{moreDifferentParam}", UriInfoUtil.getPathTemplate(uriInfo)); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java deleted file mode 100644 index 019677cce..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.util.ua; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import com.vdurmont.semver4j.Semver; -import java.util.stream.Stream; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -class UserAgentUtilTest { - - @ParameterizedTest - @MethodSource - void testParseUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) - throws UnrecognizedUserAgentException { - assertEquals(expectedUserAgent, UserAgentUtil.parseUserAgentString(userAgentString)); - } - - @SuppressWarnings("unused") - private static Stream testParseUserAgentString() { - return Stream.of( - Arguments.of("Signal-Android/4.68.3 Android/25", - new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25")), - Arguments.of("Signal-Android 4.53.7 (Android 8.1)", - new UserAgent(ClientPlatform.ANDROID, new Semver("4.53.7"), "(Android 8.1)")) - ); - } - - @ParameterizedTest - @MethodSource - void testParseBogusUserAgentString(final String userAgentString) { - assertThrows(UnrecognizedUserAgentException.class, () -> UserAgentUtil.parseUserAgentString(userAgentString)); - } - - @SuppressWarnings("unused") - private static Stream testParseBogusUserAgentString() { - return Stream.of( - null, - "This is obviously not a reasonable User-Agent string.", - "Signal-Android/4.6-8.3.unreasonableversionstring-17" - ); - } - - @ParameterizedTest - @MethodSource("argumentsForTestParseStandardUserAgentString") - void testParseStandardUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) { - assertEquals(expectedUserAgent, UserAgentUtil.parseStandardUserAgentString(userAgentString)); - } - - private static Stream argumentsForTestParseStandardUserAgentString() { - return Stream.of( - Arguments.of("This is obviously not a reasonable User-Agent string.", null), - Arguments.of("Signal-Android/4.68.3 Android/25", - new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25")), - Arguments.of("Signal-Android/4.68.3", new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"))), - Arguments.of("Signal-Desktop/1.2.3 Linux", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Linux")), - Arguments.of("Signal-Desktop/1.2.3 macOS", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "macOS")), - Arguments.of("Signal-Desktop/1.2.3 Windows", - new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Windows")), - Arguments.of("Signal-Desktop/1.2.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"))), - Arguments.of("Signal-Desktop/1.32.0-beta.3", - new UserAgent(ClientPlatform.DESKTOP, new Semver("1.32.0-beta.3"))), - Arguments.of("Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", - new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)")), - Arguments.of("Signal-iOS/3.9.0 iOS/14.2", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "iOS/14.2")), - Arguments.of("Signal-iOS/3.9.0", new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"))) - ); - } - - @ParameterizedTest - @MethodSource - void testParseLegacyUserAgentString(final String userAgentString, final UserAgent expectedUserAgent) { - assertEquals(expectedUserAgent, UserAgentUtil.parseLegacyUserAgentString(userAgentString)); - } - - @SuppressWarnings("unused") - private static Stream testParseLegacyUserAgentString() { - return Stream.of( - Arguments.of("This is obviously not a reasonable User-Agent string.", null), - Arguments.of("Signal-Android 4.53.7 (Android 8.1)", - new UserAgent(ClientPlatform.ANDROID, new Semver("4.53.7"), "(Android 8.1)")), - Arguments.of("Signal Desktop 1.2.3", new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"))), - Arguments.of("Signal Desktop 1.32.0-beta.3", - new UserAgent(ClientPlatform.DESKTOP, new Semver("1.32.0-beta.3"))), - Arguments.of("Signal/3.9.0 (iPhone; iOS 12.2; Scale/3.00)", - new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)")) - ); - } -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionIntegrationTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionIntegrationTest.java deleted file mode 100644 index d47213ba0..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionIntegrationTest.java +++ /dev/null @@ -1,371 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atMost; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; -import java.io.IOException; -import java.time.Clock; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.entities.MessageProtos; -import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; -import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.DynamoDbExtension; -import org.whispersystems.textsecuregcm.storage.MessagesCache; -import org.whispersystems.textsecuregcm.storage.MessagesDynamoDb; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.storage.ReportMessageManager; -import org.whispersystems.textsecuregcm.tests.util.MessagesDynamoDbExtension; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.websocket.WebSocketClient; -import org.whispersystems.websocket.messages.WebSocketResponseMessage; -import reactor.core.scheduler.Schedulers; - -class WebSocketConnectionIntegrationTest { - - @RegisterExtension - static DynamoDbExtension dynamoDbExtension = MessagesDynamoDbExtension.build(); - - @RegisterExtension - static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); - - private ExecutorService sharedExecutorService; - private MessagesDynamoDb messagesDynamoDb; - private MessagesCache messagesCache; - private ReportMessageManager reportMessageManager; - private Account account; - private Device device; - private WebSocketClient webSocketClient; - private ScheduledExecutorService retrySchedulingExecutor; - - private long serialTimestamp = System.currentTimeMillis(); - - @BeforeEach - void setUp() throws Exception { - - sharedExecutorService = Executors.newSingleThreadExecutor(); - messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(), - REDIS_CLUSTER_EXTENSION.getRedisCluster(), Clock.systemUTC(), sharedExecutorService, sharedExecutorService); - messagesDynamoDb = new MessagesDynamoDb(dynamoDbExtension.getDynamoDbClient(), - dynamoDbExtension.getDynamoDbAsyncClient(), MessagesDynamoDbExtension.TABLE_NAME, Duration.ofDays(7), - sharedExecutorService); - reportMessageManager = mock(ReportMessageManager.class); - account = mock(Account.class); - device = mock(Device.class); - webSocketClient = mock(WebSocketClient.class); - retrySchedulingExecutor = Executors.newSingleThreadScheduledExecutor(); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(device.getId()).thenReturn(1L); - } - - @AfterEach - void tearDown() throws Exception { - sharedExecutorService.shutdown(); - sharedExecutorService.awaitTermination(2, TimeUnit.SECONDS); - - retrySchedulingExecutor.shutdown(); - retrySchedulingExecutor.awaitTermination(2, TimeUnit.SECONDS); - } - - @ParameterizedTest - @CsvSource({ - "207, 173", - "323, 0", - "0, 221", - }) - void testProcessStoredMessages(final int persistedMessageCount, final int cachedMessageCount) { - final WebSocketConnection webSocketConnection = new WebSocketConnection( - mock(ReceiptSender.class), - new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), - new AuthenticatedAccount(() -> new Pair<>(account, device)), - device, - webSocketClient, - retrySchedulingExecutor); - - final List expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount); - - assertTimeoutPreemptively(Duration.ofSeconds(15), () -> { - - { - final List persistedMessages = new ArrayList<>(persistedMessageCount); - - for (int i = 0; i < persistedMessageCount; i++) { - final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID()); - - persistedMessages.add(envelope); - expectedMessages.add(envelope); - } - - messagesDynamoDb.store(persistedMessages, account.getUuid(), device.getId()); - } - - for (int i = 0; i < cachedMessageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope envelope = generateRandomMessage(messageGuid); - - messagesCache.insert(messageGuid, account.getUuid(), device.getId(), envelope); - expectedMessages.add(envelope); - } - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - final AtomicBoolean queueCleared = new AtomicBoolean(false); - - when(successResponse.getStatus()).thenReturn(200); - when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/message"), anyList(), any())) - .thenReturn(CompletableFuture.completedFuture(successResponse)); - - when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), anyList(), any())).thenAnswer( - (Answer>) invocation -> { - synchronized (queueCleared) { - queueCleared.set(true); - queueCleared.notifyAll(); - } - - return CompletableFuture.completedFuture(successResponse); - }); - - webSocketConnection.processStoredMessages(); - - synchronized (queueCleared) { - while (!queueCleared.get()) { - queueCleared.wait(); - } - } - - @SuppressWarnings("unchecked") final ArgumentCaptor> messageBodyCaptor = ArgumentCaptor.forClass( - Optional.class); - - verify(webSocketClient, times(persistedMessageCount + cachedMessageCount)).sendRequest(eq("PUT"), - eq("/api/v1/message"), anyList(), messageBodyCaptor.capture()); - verify(webSocketClient).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), anyList(), eq(Optional.empty())); - - final List sentMessages = new ArrayList<>(); - - for (final Optional maybeMessageBody : messageBodyCaptor.getAllValues()) { - maybeMessageBody.ifPresent(messageBytes -> { - try { - sentMessages.add(MessageProtos.Envelope.parseFrom(messageBytes)); - } catch (final InvalidProtocolBufferException e) { - fail("Could not parse sent message"); - } - }); - } - - assertEquals(expectedMessages, sentMessages); - }); - } - - @Test - void testProcessStoredMessagesClientClosed() { - final WebSocketConnection webSocketConnection = new WebSocketConnection( - mock(ReceiptSender.class), - new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), - new AuthenticatedAccount(() -> new Pair<>(account, device)), - device, - webSocketClient, - retrySchedulingExecutor); - - final int persistedMessageCount = 207; - final int cachedMessageCount = 173; - - final List expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount); - - assertTimeoutPreemptively(Duration.ofSeconds(15), () -> { - - { - final List persistedMessages = new ArrayList<>(persistedMessageCount); - - for (int i = 0; i < persistedMessageCount; i++) { - final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID()); - persistedMessages.add(envelope); - expectedMessages.add(envelope); - } - - messagesDynamoDb.store(persistedMessages, account.getUuid(), device.getId()); - } - - for (int i = 0; i < cachedMessageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope envelope = generateRandomMessage(messageGuid); - messagesCache.insert(messageGuid, account.getUuid(), device.getId(), envelope); - - expectedMessages.add(envelope); - } - - when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/message"), anyList(), any())).thenReturn( - CompletableFuture.failedFuture(new IOException("Connection closed"))); - - webSocketConnection.processStoredMessages(); - - //noinspection unchecked - ArgumentCaptor> messageBodyCaptor = ArgumentCaptor.forClass(Optional.class); - - verify(webSocketClient, atMost(persistedMessageCount + cachedMessageCount)).sendRequest(eq("PUT"), - eq("/api/v1/message"), anyList(), messageBodyCaptor.capture()); - verify(webSocketClient, never()).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), anyList(), - eq(Optional.empty())); - - final List sentMessages = messageBodyCaptor.getAllValues().stream() - .map(Optional::get) - .map(messageBytes -> { - try { - return Envelope.parseFrom(messageBytes); - } catch (InvalidProtocolBufferException e) { - throw new RuntimeException(e); - } - }).toList(); - - assertTrue(expectedMessages.containsAll(sentMessages)); - }); - } - - @Test - void testProcessStoredMessagesSendFutureTimeout() { - final WebSocketConnection webSocketConnection = new WebSocketConnection( - mock(ReceiptSender.class), - new MessagesManager(messagesDynamoDb, messagesCache, reportMessageManager, sharedExecutorService), - new AuthenticatedAccount(() -> new Pair<>(account, device)), - device, - webSocketClient, - 100, // use a very short timeout, so that this test completes quickly - retrySchedulingExecutor, - Schedulers.boundedElastic()); - - final int persistedMessageCount = 207; - final int cachedMessageCount = 173; - - final List expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount); - - assertTimeoutPreemptively(Duration.ofSeconds(15), () -> { - - { - final List persistedMessages = new ArrayList<>(persistedMessageCount); - - for (int i = 0; i < persistedMessageCount; i++) { - final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID()); - persistedMessages.add(envelope); - expectedMessages.add(envelope); - } - - messagesDynamoDb.store(persistedMessages, account.getUuid(), device.getId()); - } - - for (int i = 0; i < cachedMessageCount; i++) { - final UUID messageGuid = UUID.randomUUID(); - final MessageProtos.Envelope envelope = generateRandomMessage(messageGuid); - messagesCache.insert(messageGuid, account.getUuid(), device.getId(), envelope); - - expectedMessages.add(envelope); - } - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - final CompletableFuture neverCompleting = new CompletableFuture<>(); - - // for the first message, return a future that never completes - when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/message"), anyList(), any())) - .thenReturn(neverCompleting) - .thenReturn(CompletableFuture.completedFuture(successResponse)); - - when(webSocketClient.isOpen()).thenReturn(true); - - final AtomicBoolean queueCleared = new AtomicBoolean(false); - - when(webSocketClient.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), anyList(), any())).thenAnswer( - (Answer>) invocation -> { - synchronized (queueCleared) { - queueCleared.set(true); - queueCleared.notifyAll(); - } - - return CompletableFuture.completedFuture(successResponse); - }); - - webSocketConnection.processStoredMessages(); - - synchronized (queueCleared) { - while (!queueCleared.get()) { - queueCleared.wait(); - } - } - - //noinspection unchecked - ArgumentCaptor> messageBodyCaptor = ArgumentCaptor.forClass(Optional.class); - - // We expect all of the messages from both pools to be sent, plus one for the future that times out - verify(webSocketClient, atMost(persistedMessageCount + cachedMessageCount + 1)).sendRequest(eq("PUT"), - eq("/api/v1/message"), anyList(), messageBodyCaptor.capture()); - - verify(webSocketClient).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), anyList(), eq(Optional.empty())); - - final List sentMessages = messageBodyCaptor.getAllValues().stream() - .map(Optional::get) - .map(messageBytes -> { - try { - return Envelope.parseFrom(messageBytes); - } catch (InvalidProtocolBufferException e) { - throw new RuntimeException(e); - } - }).toList(); - - assertTrue(expectedMessages.containsAll(sentMessages)); - }); - } - - private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid) { - final long timestamp = serialTimestamp++; - - return MessageProtos.Envelope.newBuilder() - .setTimestamp(timestamp) - .setServerTimestamp(timestamp) - .setContent(ByteString.copyFromUtf8(RandomStringUtils.randomAlphanumeric(256))) - .setType(MessageProtos.Envelope.Type.CIPHERTEXT) - .setServerGuid(messageGuid.toString()) - .setDestinationUuid(UUID.randomUUID().toString()) - .build(); - } - -} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java deleted file mode 100644 index d7a9b793f..000000000 --- a/service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java +++ /dev/null @@ -1,879 +0,0 @@ -/* - * Copyright 2013-2022 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.whispersystems.textsecuregcm.websocket; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.nullable; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.anyInt; -import static org.mockito.Mockito.anyLong; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; - -import com.google.common.net.HttpHeaders; -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; -import io.dropwizard.auth.basic.BasicCredentials; -import io.lettuce.core.RedisException; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; -import org.eclipse.jetty.websocket.api.UpgradeRequest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.mockito.stubbing.Answer; -import org.whispersystems.textsecuregcm.auth.AccountAuthenticator; -import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; -import org.whispersystems.textsecuregcm.push.ClientPresenceManager; -import org.whispersystems.textsecuregcm.push.PushNotificationManager; -import org.whispersystems.textsecuregcm.push.ReceiptSender; -import org.whispersystems.textsecuregcm.storage.Account; -import org.whispersystems.textsecuregcm.storage.AccountsManager; -import org.whispersystems.textsecuregcm.storage.Device; -import org.whispersystems.textsecuregcm.storage.MessagesManager; -import org.whispersystems.textsecuregcm.util.Pair; -import org.whispersystems.websocket.WebSocketClient; -import org.whispersystems.websocket.auth.WebSocketAuthenticator.AuthenticationResult; -import org.whispersystems.websocket.messages.WebSocketResponseMessage; -import org.whispersystems.websocket.session.WebSocketSessionContext; -import reactor.core.publisher.Flux; -import reactor.core.publisher.FluxSink; -import reactor.core.scheduler.Schedulers; -import reactor.test.StepVerifier; - -class WebSocketConnectionTest { - - private static final String VALID_USER = "+14152222222"; - private static final String INVALID_USER = "+14151111111"; - - private static final int SOURCE_DEVICE_ID = 1; - - private static final String VALID_PASSWORD = "secure"; - private static final String INVALID_PASSWORD = "insecure"; - - private AccountAuthenticator accountAuthenticator; - private AccountsManager accountsManager; - private Account account; - private Device device; - private AuthenticatedAccount auth; - private UpgradeRequest upgradeRequest; - private ReceiptSender receiptSender; - private ScheduledExecutorService retrySchedulingExecutor; - - @BeforeEach - void setup() { - accountAuthenticator = mock(AccountAuthenticator.class); - accountsManager = mock(AccountsManager.class); - account = mock(Account.class); - device = mock(Device.class); - auth = new AuthenticatedAccount(() -> new Pair<>(account, device)); - upgradeRequest = mock(UpgradeRequest.class); - receiptSender = mock(ReceiptSender.class); - retrySchedulingExecutor = mock(ScheduledExecutorService.class); - } - - @AfterEach - void teardown() { - StepVerifier.resetDefaultTimeout(); - } - - @Test - void testCredentials() { - MessagesManager storedMessages = mock(MessagesManager.class); - WebSocketAccountAuthenticator webSocketAuthenticator = new WebSocketAccountAuthenticator(accountAuthenticator); - AuthenticatedConnectListener connectListener = new AuthenticatedConnectListener(receiptSender, storedMessages, - mock(PushNotificationManager.class), mock(ClientPresenceManager.class), - retrySchedulingExecutor); - WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class); - - when(accountAuthenticator.authenticate(eq(new BasicCredentials(VALID_USER, VALID_PASSWORD)))) - .thenReturn(Optional.of(new AuthenticatedAccount(() -> new Pair<>(account, device)))); - - when(accountAuthenticator.authenticate(eq(new BasicCredentials(INVALID_USER, INVALID_PASSWORD)))) - .thenReturn(Optional.empty()); - - when(upgradeRequest.getParameterMap()).thenReturn(Map.of( - "login", List.of(VALID_USER), - "password", List.of(VALID_PASSWORD))); - - AuthenticationResult account = webSocketAuthenticator.authenticate(upgradeRequest); - when(sessionContext.getAuthenticated(AuthenticatedAccount.class)).thenReturn(account.getUser().orElse(null)); - - connectListener.onWebSocketConnect(sessionContext); - - verify(sessionContext).addListener(any(WebSocketSessionContext.WebSocketEventListener.class)); - - when(upgradeRequest.getParameterMap()).thenReturn(Map.of( - "login", List.of(INVALID_USER), - "password", List.of(INVALID_PASSWORD) - )); - - account = webSocketAuthenticator.authenticate(upgradeRequest); - assertFalse(account.getUser().isPresent()); - assertTrue(account.isRequired()); - } - - @Test - void testOpen() { - MessagesManager storedMessages = mock(MessagesManager.class); - - UUID accountUuid = UUID.randomUUID(); - UUID senderOneUuid = UUID.randomUUID(); - UUID senderTwoUuid = UUID.randomUUID(); - - List outgoingMessages = List.of(createMessage(senderOneUuid, accountUuid, 1111, "first"), - createMessage(senderOneUuid, accountUuid, 2222, "second"), - createMessage(senderTwoUuid, accountUuid, 3333, "third")); - - final long deviceId = 2L; - when(device.getId()).thenReturn(deviceId); - - when(account.getNumber()).thenReturn("+14152222222"); - when(account.getUuid()).thenReturn(accountUuid); - - final Device sender1device = mock(Device.class); - - List sender1devices = List.of(sender1device); - - Account sender1 = mock(Account.class); - when(sender1.getDevices()).thenReturn(sender1devices); - - when(accountsManager.getByE164("sender1")).thenReturn(Optional.of(sender1)); - when(accountsManager.getByE164("sender2")).thenReturn(Optional.empty()); - - String userAgent = HttpHeaders.USER_AGENT; - - when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false)) - .thenReturn(Flux.fromIterable(outgoingMessages)); - - final List> futures = new LinkedList<>(); - final WebSocketClient client = mock(WebSocketClient.class); - - when(client.getUserAgent()).thenReturn(userAgent); - when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), nullable(List.class), any())) - .thenAnswer(invocation -> { - CompletableFuture future = new CompletableFuture<>(); - futures.add(future); - return future; - }); - - WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, - auth, device, client, retrySchedulingExecutor, Schedulers.immediate()); - - connection.start(); - verify(client, times(3)).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), - any()); - - assertEquals(3, futures.size()); - - WebSocketResponseMessage response = mock(WebSocketResponseMessage.class); - when(response.getStatus()).thenReturn(200); - futures.get(1).complete(response); - - futures.get(0).completeExceptionally(new IOException()); - futures.get(2).completeExceptionally(new IOException()); - - verify(storedMessages, times(1)).delete(eq(accountUuid), eq(deviceId), - eq(UUID.fromString(outgoingMessages.get(1).getServerGuid())), eq(outgoingMessages.get(1).getServerTimestamp())); - verify(receiptSender, times(1)).sendReceipt(eq(accountUuid), eq(deviceId), eq(senderOneUuid), - eq(2222L)); - - connection.stop(); - verify(client).close(anyInt(), anyString()); - } - - @Test - public void testOnlineSend() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - final UUID accountUuid = UUID.randomUUID(); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(accountUuid); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean())) - .thenReturn(Flux.empty()) - .thenReturn(Flux.just(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first"))) - .thenReturn(Flux.just(createMessage(UUID.randomUUID(), UUID.randomUUID(), 2222, "second"))) - .thenReturn(Flux.empty()); - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - final AtomicInteger sendCounter = new AtomicInteger(0); - - when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))) - .thenAnswer(invocation -> { - synchronized (sendCounter) { - sendCounter.incrementAndGet(); - sendCounter.notifyAll(); - } - - return CompletableFuture.completedFuture(successResponse); - }); - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - // This is a little hacky and non-obvious, but because the first call to getMessagesForDevice returns empty list of - // messages, the call to CompletableFuture.allOf(...) in processStoredMessages will produce an instantly-succeeded - // future, and the whenComplete method will get called immediately on THIS thread, so we don't need to synchronize - // or wait for anything. - connection.start(); - - connection.handleNewMessagesAvailable(); - - synchronized (sendCounter) { - while (sendCounter.get() < 1) { - sendCounter.wait(); - } - } - - connection.handleNewMessagesAvailable(); - - synchronized (sendCounter) { - while (sendCounter.get() < 2) { - sendCounter.wait(); - } - } - }); - - verify(client, times(1)).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())); - verify(client, times(2)).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class)); - } - - @Test - void testPendingSend() { - MessagesManager storedMessages = mock(MessagesManager.class); - - final UUID accountUuid = UUID.randomUUID(); - final UUID senderTwoUuid = UUID.randomUUID(); - - final Envelope firstMessage = Envelope.newBuilder() - .setServerGuid(UUID.randomUUID().toString()) - .setSourceUuid(UUID.randomUUID().toString()) - .setDestinationUuid(accountUuid.toString()) - .setUpdatedPni(UUID.randomUUID().toString()) - .setTimestamp(System.currentTimeMillis()) - .setSourceDevice(1) - .setType(Envelope.Type.CIPHERTEXT) - .build(); - - final Envelope secondMessage = Envelope.newBuilder() - .setServerGuid(UUID.randomUUID().toString()) - .setSourceUuid(senderTwoUuid.toString()) - .setDestinationUuid(accountUuid.toString()) - .setTimestamp(System.currentTimeMillis()) - .setSourceDevice(2) - .setType(Envelope.Type.CIPHERTEXT) - .build(); - - final List pendingMessages = List.of(firstMessage, secondMessage); - - final long deviceId = 2L; - when(device.getId()).thenReturn(deviceId); - - when(account.getNumber()).thenReturn("+14152222222"); - when(account.getUuid()).thenReturn(accountUuid); - - final Device sender1device = mock(Device.class); - - List sender1devices = List.of(sender1device); - - Account sender1 = mock(Account.class); - when(sender1.getDevices()).thenReturn(sender1devices); - - when(accountsManager.getByE164("sender1")).thenReturn(Optional.of(sender1)); - when(accountsManager.getByE164("sender2")).thenReturn(Optional.empty()); - - String userAgent = HttpHeaders.USER_AGENT; - - when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false)) - .thenReturn(Flux.fromIterable(pendingMessages)); - - final List> futures = new LinkedList<>(); - final WebSocketClient client = mock(WebSocketClient.class); - - when(client.getUserAgent()).thenReturn(userAgent); - when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(), any())) - .thenAnswer((Answer>) invocationOnMock -> { - CompletableFuture future = new CompletableFuture<>(); - futures.add(future); - return future; - }); - - WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, - auth, device, client, retrySchedulingExecutor, Schedulers.immediate()); - - connection.start(); - - verify(client, times(2)).sendRequest(eq("PUT"), eq("/api/v1/message"), any(), any()); - - assertEquals(futures.size(), 2); - - WebSocketResponseMessage response = mock(WebSocketResponseMessage.class); - when(response.getStatus()).thenReturn(200); - futures.get(1).complete(response); - futures.get(0).completeExceptionally(new IOException()); - - verify(receiptSender, times(1)).sendReceipt(eq(account.getUuid()), eq(deviceId), eq(senderTwoUuid), - eq(secondMessage.getTimestamp())); - - connection.stop(); - verify(client).close(anyInt(), anyString()); - } - - @Test - void testProcessStoredMessageConcurrency() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(UUID.randomUUID()); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - final AtomicBoolean threadWaiting = new AtomicBoolean(false); - final AtomicBoolean returnMessageList = new AtomicBoolean(false); - - when( - messagesManager.getMessagesForDeviceReactive(account.getUuid(), 1L, false)) - .thenAnswer(invocation -> { - synchronized (threadWaiting) { - threadWaiting.set(true); - threadWaiting.notifyAll(); - } - - synchronized (returnMessageList) { - while (!returnMessageList.get()) { - returnMessageList.wait(); - } - } - - return Flux.empty(); - }); - - final Thread[] threads = new Thread[10]; - final CountDownLatch unblockedThreadsLatch = new CountDownLatch(threads.length - 1); - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - for (int i = 0; i < threads.length; i++) { - threads[i] = new Thread(() -> { - connection.processStoredMessages(); - unblockedThreadsLatch.countDown(); - }); - - threads[i].start(); - } - - unblockedThreadsLatch.await(); - - synchronized (threadWaiting) { - while (!threadWaiting.get()) { - threadWaiting.wait(); - } - } - - synchronized (returnMessageList) { - returnMessageList.set(true); - returnMessageList.notifyAll(); - } - - for (final Thread thread : threads) { - thread.join(); - } - }); - - verify(messagesManager).getMessagesForDeviceReactive(any(UUID.class), anyLong(), eq(false)); - } - - @Test - void testProcessStoredMessagesMultiplePages() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - when(account.getNumber()).thenReturn("+18005551234"); - final UUID accountUuid = UUID.randomUUID(); - when(account.getUuid()).thenReturn(accountUuid); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - final List firstPageMessages = - List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first"), - createMessage(UUID.randomUUID(), UUID.randomUUID(), 2222, "second")); - - final List secondPageMessages = - List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 3333, "third")); - - when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), eq(false))) - .thenReturn(Flux.fromStream(Stream.concat(firstPageMessages.stream(), secondPageMessages.stream()))); - - when(messagesManager.delete(eq(accountUuid), eq(1L), any(), any())) - .thenReturn(CompletableFuture.completedFuture(null)); - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - final CountDownLatch queueEmptyLatch = new CountDownLatch(1); - - when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))) - .thenAnswer(invocation -> { - return CompletableFuture.completedFuture(successResponse); - }); - - when(client.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()))) - .thenAnswer(invocation -> { - queueEmptyLatch.countDown(); - return CompletableFuture.completedFuture(successResponse); - }); - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - connection.processStoredMessages(); - - queueEmptyLatch.await(); - }); - - verify(client, times(firstPageMessages.size() + secondPageMessages.size())).sendRequest(eq("PUT"), - eq("/api/v1/message"), any(List.class), any(Optional.class)); - verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())); - } - - @Test - void testProcessStoredMessagesContainsSenderUuid() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - when(account.getNumber()).thenReturn("+18005551234"); - final UUID accountUuid = UUID.randomUUID(); - when(account.getUuid()).thenReturn(accountUuid); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - final UUID senderUuid = UUID.randomUUID(); - final List messages = List.of( - createMessage(senderUuid, UUID.randomUUID(), 1111L, "message the first")); - - when(messagesManager.getMessagesForDeviceReactive(account.getUuid(), 1L, false)) - .thenReturn(Flux.fromIterable(messages)) - .thenReturn(Flux.empty()); - - when(messagesManager.delete(eq(accountUuid), eq(1L), any(UUID.class), any())) - .thenReturn(CompletableFuture.completedFuture(null)); - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - final CountDownLatch queueEmptyLatch = new CountDownLatch(1); - - when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))).thenAnswer( - invocation -> CompletableFuture.completedFuture(successResponse)); - - when(client.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()))) - .thenAnswer(invocation -> { - queueEmptyLatch.countDown(); - return CompletableFuture.completedFuture(successResponse); - }); - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - connection.processStoredMessages(); - queueEmptyLatch.await(); - }); - - verify(client, times(messages.size())).sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), - argThat(argument -> { - if (argument.isEmpty()) { - return false; - } - - final byte[] body = argument.get(); - try { - final Envelope envelope = Envelope.parseFrom(body); - if (!envelope.hasSourceUuid() || envelope.getSourceUuid().length() == 0) { - return false; - } - return envelope.getSourceUuid().equals(senderUuid.toString()); - } catch (InvalidProtocolBufferException e) { - return false; - } - })); - verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())); - } - - @Test - void testProcessStoredMessagesSingleEmptyCall() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - final UUID accountUuid = UUID.randomUUID(); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(accountUuid); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean())) - .thenReturn(Flux.empty()); - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - // This is a little hacky and non-obvious, but because we're always returning an empty list of messages, the call to - // CompletableFuture.allOf(...) in processStoredMessages will produce an instantly-succeeded future, and the - // whenComplete method will get called immediately on THIS thread, so we don't need to synchronize or wait for - // anything. - connection.processStoredMessages(); - connection.processStoredMessages(); - - verify(client, times(1)).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())); - } - - @Test - public void testRequeryOnStateMismatch() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - final UUID accountUuid = UUID.randomUUID(); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(accountUuid); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - final List firstPageMessages = - List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first"), - createMessage(UUID.randomUUID(), UUID.randomUUID(), 2222, "second")); - - final List secondPageMessages = - List.of(createMessage(UUID.randomUUID(), UUID.randomUUID(), 3333, "third")); - - when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean())) - .thenReturn(Flux.fromIterable(firstPageMessages)) - .thenReturn(Flux.fromIterable(secondPageMessages)) - .thenReturn(Flux.empty()); - - when(messagesManager.delete(eq(accountUuid), eq(1L), any(), any())) - .thenReturn(CompletableFuture.completedFuture(null)); - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - final CountDownLatch queueEmptyLatch = new CountDownLatch(1); - - when(client.sendRequest(eq("PUT"), eq("/api/v1/message"), any(List.class), any(Optional.class))) - .thenAnswer(invocation -> { - connection.handleNewMessagesAvailable(); - - return CompletableFuture.completedFuture(successResponse); - }); - - when(client.sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty()))) - .thenAnswer(invocation -> { - queueEmptyLatch.countDown(); - return CompletableFuture.completedFuture(successResponse); - }); - - assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { - connection.processStoredMessages(); - - queueEmptyLatch.await(); - }); - - verify(client, times(firstPageMessages.size() + secondPageMessages.size())).sendRequest(eq("PUT"), - eq("/api/v1/message"), any(List.class), any(Optional.class)); - verify(client).sendRequest(eq("PUT"), eq("/api/v1/queue/empty"), any(List.class), eq(Optional.empty())); - } - - @Test - void testProcessCachedMessagesOnly() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - final UUID accountUuid = UUID.randomUUID(); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(accountUuid); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean())) - .thenReturn(Flux.empty()); - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - // This is a little hacky and non-obvious, but because we're always returning an empty list of messages, the call to - // CompletableFuture.allOf(...) in processStoredMessages will produce an instantly-succeeded future, and the - // whenComplete method will get called immediately on THIS thread, so we don't need to synchronize or wait for - // anything. - connection.processStoredMessages(); - - verify(messagesManager).getMessagesForDeviceReactive(account.getUuid(), device.getId(), false); - - connection.handleNewMessagesAvailable(); - - verify(messagesManager).getMessagesForDeviceReactive(account.getUuid(), device.getId(), true); - } - - @Test - void testProcessDatabaseMessagesAfterPersist() { - final MessagesManager messagesManager = mock(MessagesManager.class); - final WebSocketClient client = mock(WebSocketClient.class); - final WebSocketConnection connection = new WebSocketConnection(receiptSender, messagesManager, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - final UUID accountUuid = UUID.randomUUID(); - - when(account.getNumber()).thenReturn("+18005551234"); - when(account.getUuid()).thenReturn(accountUuid); - when(device.getId()).thenReturn(1L); - when(client.isOpen()).thenReturn(true); - - when(messagesManager.getMessagesForDeviceReactive(eq(accountUuid), eq(1L), anyBoolean())) - .thenReturn(Flux.empty()); - - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - - // This is a little hacky and non-obvious, but because we're always returning an empty list of messages, the call to - // CompletableFuture.allOf(...) in processStoredMessages will produce an instantly-succeeded future, and the - // whenComplete method will get called immediately on THIS thread, so we don't need to synchronize or wait for - // anything. - connection.processStoredMessages(); - connection.handleMessagesPersisted(); - - verify(messagesManager, times(2)).getMessagesForDeviceReactive(account.getUuid(), device.getId(), false); - } - - @Test - void testRetrieveMessageException() { - MessagesManager storedMessages = mock(MessagesManager.class); - - UUID accountUuid = UUID.randomUUID(); - - when(device.getId()).thenReturn(2L); - - when(account.getNumber()).thenReturn("+14152222222"); - when(account.getUuid()).thenReturn(accountUuid); - - when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false)) - .thenReturn(Flux.error(new RedisException("OH NO"))); - - when(retrySchedulingExecutor.schedule(any(Runnable.class), anyLong(), any())).thenAnswer( - (Answer>) invocation -> { - invocation.getArgument(0, Runnable.class).run(); - return mock(ScheduledFuture.class); - }); - - final WebSocketClient client = mock(WebSocketClient.class); - when(client.isOpen()).thenReturn(true); - - WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - connection.start(); - - verify(retrySchedulingExecutor, times(WebSocketConnection.MAX_CONSECUTIVE_RETRIES)).schedule(any(Runnable.class), - anyLong(), any()); - verify(client).close(eq(1011), anyString()); - } - - @Test - void testRetrieveMessageExceptionClientDisconnected() { - MessagesManager storedMessages = mock(MessagesManager.class); - - UUID accountUuid = UUID.randomUUID(); - - when(device.getId()).thenReturn(2L); - - when(account.getNumber()).thenReturn("+14152222222"); - when(account.getUuid()).thenReturn(accountUuid); - - when(storedMessages.getMessagesForDeviceReactive(account.getUuid(), device.getId(), false)) - .thenReturn(Flux.error(new RedisException("OH NO"))); - - final WebSocketClient client = mock(WebSocketClient.class); - when(client.isOpen()).thenReturn(false); - - WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - connection.start(); - - verify(retrySchedulingExecutor, never()).schedule(any(Runnable.class), anyLong(), any()); - verify(client, never()).close(anyInt(), anyString()); - } - - @Test - @Disabled("This test is flaky") - void testReactivePublisherLimitRate() { - MessagesManager storedMessages = mock(MessagesManager.class); - - final UUID accountUuid = UUID.randomUUID(); - - final long deviceId = 2L; - when(device.getId()).thenReturn(deviceId); - - when(account.getNumber()).thenReturn("+14152222222"); - when(account.getUuid()).thenReturn(accountUuid); - - final int totalMessages = 10; - final AtomicReference> sink = new AtomicReference<>(); - - final AtomicLong maxRequest = new AtomicLong(-1); - final Flux flux = Flux.create(s -> { - sink.set(s); - s.onRequest(n -> { - if (maxRequest.get() < n) { - maxRequest.set(n); - } - }); - }); - - when(storedMessages.getMessagesForDeviceReactive(eq(accountUuid), eq(deviceId), anyBoolean())) - .thenReturn(flux); - - final WebSocketClient client = mock(WebSocketClient.class); - when(client.isOpen()).thenReturn(true); - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - when(client.sendRequest(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(successResponse)); - when(storedMessages.delete(any(), anyLong(), any(), any())).thenReturn( - CompletableFuture.completedFuture(Optional.empty())); - - WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client, - retrySchedulingExecutor); - - connection.start(); - - StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); - - StepVerifier.create(flux, 0) - .expectSubscription() - .thenRequest(totalMessages * 2) - .then(() -> { - for (long i = 0; i < totalMessages; i++) { - sink.get().next(createMessage(UUID.randomUUID(), accountUuid, 1111 * i + 1, "message " + i)); - } - sink.get().complete(); - }) - .expectNextCount(totalMessages) - .expectComplete() - .log() - .verify(); - - assertEquals(WebSocketConnection.MESSAGE_PUBLISHER_LIMIT_RATE, maxRequest.get()); - } - - @Test - void testReactivePublisherDisposedWhenConnectionStopped() { - MessagesManager storedMessages = mock(MessagesManager.class); - - final UUID accountUuid = UUID.randomUUID(); - - final long deviceId = 2L; - when(device.getId()).thenReturn(deviceId); - - when(account.getNumber()).thenReturn("+14152222222"); - when(account.getUuid()).thenReturn(accountUuid); - - final AtomicBoolean canceled = new AtomicBoolean(); - - final Flux flux = Flux.create(s -> { - s.onRequest(n -> { - // the subscriber should request more than 1 message, but we will only send one, so that - // we are sure the subscriber is waiting for more when we stop the connection - assert n > 1; - s.next(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, "first")); - }); - - s.onCancel(() -> canceled.set(true)); - }); - when(storedMessages.getMessagesForDeviceReactive(eq(accountUuid), eq(deviceId), anyBoolean())) - .thenReturn(flux); - - final WebSocketClient client = mock(WebSocketClient.class); - when(client.isOpen()).thenReturn(true); - final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class); - when(successResponse.getStatus()).thenReturn(200); - when(client.sendRequest(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(successResponse)); - when(storedMessages.delete(any(), anyLong(), any(), any())).thenReturn( - CompletableFuture.completedFuture(Optional.empty())); - - WebSocketConnection connection = new WebSocketConnection(receiptSender, storedMessages, auth, device, client, - retrySchedulingExecutor, Schedulers.immediate()); - - connection.start(); - - verify(client).sendRequest(any(), any(), any(), any()); - - // close the connection before the publisher completes - connection.stop(); - - StepVerifier.setDefaultTimeout(Duration.ofSeconds(2)); - - StepVerifier.create(flux) - .expectSubscription() - .expectNextCount(1) - .then(() -> assertTrue(canceled.get())) - // this is not entirely intuitive, but expecting a timeout is the recommendation for verifying cancellation - .expectTimeout(Duration.ofMillis(100)) - .log() - .verify(); - } - - private Envelope createMessage(UUID senderUuid, UUID destinationUuid, long timestamp, String content) { - return Envelope.newBuilder() - .setServerGuid(UUID.randomUUID().toString()) - .setType(Envelope.Type.CIPHERTEXT) - .setTimestamp(timestamp) - .setServerTimestamp(0) - .setSourceUuid(senderUuid.toString()) - .setSourceDevice(SOURCE_DEVICE_ID) - .setDestinationUuid(destinationUuid.toString()) - .setContent(ByteString.copyFrom(content.getBytes(StandardCharsets.UTF_8))) - .build(); - } - -} diff --git a/service/src/test/resources/fixtures/contact.json b/service/src/test/resources/fixtures/contact.json deleted file mode 100644 index 0fd1282f4..000000000 --- a/service/src/test/resources/fixtures/contact.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "token" : "BQVVHxMt5zAFXA" -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/contact.relay.json b/service/src/test/resources/fixtures/contact.relay.json deleted file mode 100644 index 5661661c3..000000000 --- a/service/src/test/resources/fixtures/contact.relay.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "token" : "BQVVHxMt5zAFXA", - "relay" : "whisper" -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/contact.relay.video.json b/service/src/test/resources/fixtures/contact.relay.video.json deleted file mode 100644 index e242967ca..000000000 --- a/service/src/test/resources/fixtures/contact.relay.video.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "token" : "BQVVHxMt5zAFXA", - "voice" : true, - "video" : true, - "relay" : "whisper" -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/contact.relay.voice.json b/service/src/test/resources/fixtures/contact.relay.voice.json deleted file mode 100644 index 6368ffb89..000000000 --- a/service/src/test/resources/fixtures/contact.relay.voice.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "token" : "BQVVHxMt5zAFXA", - "voice" : true, - "relay" : "whisper" -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/current_message_extra_device.json b/service/src/test/resources/fixtures/current_message_extra_device.json deleted file mode 100644 index cd9b8fcb8..000000000 --- a/service/src/test/resources/fixtures/current_message_extra_device.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "messages" : [{ - "type" : 1, - "destinationDeviceId" : 1, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }, - { - "type" : 1, - "destinationDeviceId" : 3, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }] -} diff --git a/service/src/test/resources/fixtures/current_message_multi_device.json b/service/src/test/resources/fixtures/current_message_multi_device.json deleted file mode 100644 index 4236372fe..000000000 --- a/service/src/test/resources/fixtures/current_message_multi_device.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "messages" : [{ - "type" : 1, - "destinationDeviceId" : 1, - "destinationRegistrationId" : 222, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }, - { - "type" : 1, - "destinationDeviceId" : 2, - "destinationRegistrationId" : 333, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }] -} diff --git a/service/src/test/resources/fixtures/current_message_multi_device_not_urgent.json b/service/src/test/resources/fixtures/current_message_multi_device_not_urgent.json deleted file mode 100644 index c07ca93a2..000000000 --- a/service/src/test/resources/fixtures/current_message_multi_device_not_urgent.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "urgent": false, - "messages": [ - { - "type": 1, - "destinationDeviceId": 1, - "destinationRegistrationId": 222, - "content": "Zm9vYmFyego", - "timestamp": 1234 - }, - { - "type": 1, - "destinationDeviceId": 2, - "destinationRegistrationId": 333, - "content": "Zm9vYmFyego", - "timestamp": 1234 - } - ] -} diff --git a/service/src/test/resources/fixtures/current_message_multi_device_pni.json b/service/src/test/resources/fixtures/current_message_multi_device_pni.json deleted file mode 100644 index 0be6b7832..000000000 --- a/service/src/test/resources/fixtures/current_message_multi_device_pni.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "messages" : [{ - "type" : 1, - "destinationDeviceId" : 1, - "destinationRegistrationId" : 2222, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }, - { - "type" : 1, - "destinationDeviceId" : 2, - "destinationRegistrationId" : 3333, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }] -} diff --git a/service/src/test/resources/fixtures/current_message_null_message_in_list.json b/service/src/test/resources/fixtures/current_message_null_message_in_list.json deleted file mode 100644 index 11dcf0b5c..000000000 --- a/service/src/test/resources/fixtures/current_message_null_message_in_list.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "messages" : [ { - "type" : 1, - "destinationDeviceId" : 1, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }, null ] -} diff --git a/service/src/test/resources/fixtures/current_message_registration_id.json b/service/src/test/resources/fixtures/current_message_registration_id.json deleted file mode 100644 index 1b7b03ad5..000000000 --- a/service/src/test/resources/fixtures/current_message_registration_id.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "messages" : [{ - "type" : 1, - "destinationDeviceId" : 1, - "destinationRegistrationId" : 222, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }, - { - "type" : 1, - "destinationDeviceId" : 2, - "destinationRegistrationId" : 999, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }] -} diff --git a/service/src/test/resources/fixtures/current_message_single_device.json b/service/src/test/resources/fixtures/current_message_single_device.json deleted file mode 100644 index d35253c3a..000000000 --- a/service/src/test/resources/fixtures/current_message_single_device.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "messages" : [{ - "type" : 1, - "destinationDeviceId" : 1, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }] -} diff --git a/service/src/test/resources/fixtures/current_message_single_device_bad_type.json b/service/src/test/resources/fixtures/current_message_single_device_bad_type.json deleted file mode 100644 index 4822afcfb..000000000 --- a/service/src/test/resources/fixtures/current_message_single_device_bad_type.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "messages" : [{ - "type" : 7, - "destinationDeviceId" : 1, - "content" : "Zm9vYmFyego", - "timestamp" : 1234 - }] -} diff --git a/service/src/test/resources/fixtures/current_message_single_device_not_urgent.json b/service/src/test/resources/fixtures/current_message_single_device_not_urgent.json deleted file mode 100644 index 78aa66af3..000000000 --- a/service/src/test/resources/fixtures/current_message_single_device_not_urgent.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "urgent": false, - "messages": [ - { - "type": 1, - "destinationDeviceId": 1, - "content": "Zm9vYmFyego", - "timestamp": 1234 - } - ] -} diff --git a/service/src/test/resources/fixtures/current_message_single_device_server_receipt_type.json b/service/src/test/resources/fixtures/current_message_single_device_server_receipt_type.json deleted file mode 100644 index 34646ef4d..000000000 --- a/service/src/test/resources/fixtures/current_message_single_device_server_receipt_type.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "messages": [ - { - "type": 5, - "destinationDeviceId": 1, - "content": "Zm9vYmFyego", - "timestamp": 1234 - } - ] -} diff --git a/service/src/test/resources/fixtures/fixer.res.json b/service/src/test/resources/fixtures/fixer.res.json deleted file mode 100644 index b388c2e7c..000000000 --- a/service/src/test/resources/fixtures/fixer.res.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "success": true, - "timestamp": 1519296206, - "base": "EUR", - "date": "2021-08-01", - "rates": { - "AUD": 1.566015, - "CAD": 1.560132, - "CHF": 1.154727, - "CNY": 7.827874, - "GBP": 0.882047, - "JPY": 132.360679, - "USD": 1.23396 - } -} diff --git a/service/src/test/resources/fixtures/mismatched_registration_id.json b/service/src/test/resources/fixtures/mismatched_registration_id.json deleted file mode 100644 index 273eea57d..000000000 --- a/service/src/test/resources/fixtures/mismatched_registration_id.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "staleDevices" : [2] -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/missing_device_response.json b/service/src/test/resources/fixtures/missing_device_response.json deleted file mode 100644 index a0ad77d79..000000000 --- a/service/src/test/resources/fixtures/missing_device_response.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "missingDevices" : [2], - "extraDevices" : [] -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/missing_device_response2.json b/service/src/test/resources/fixtures/missing_device_response2.json deleted file mode 100644 index 960900cc5..000000000 --- a/service/src/test/resources/fixtures/missing_device_response2.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "missingDevices" : [2], - "extraDevices" : [3] -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/prekey.json b/service/src/test/resources/fixtures/prekey.json deleted file mode 100644 index 4a8a277bc..000000000 --- a/service/src/test/resources/fixtures/prekey.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "deviceId" : 1, - "keyId" : 1234, - "publicKey" : "test", - "identityKey" : "identityTest", - "registrationId" : 987 -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/prekey_v2.json b/service/src/test/resources/fixtures/prekey_v2.json deleted file mode 100644 index 1feafdaea..000000000 --- a/service/src/test/resources/fixtures/prekey_v2.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "keyId" : 1234, - "publicKey" : "test" -} \ No newline at end of file diff --git a/service/src/test/resources/fixtures/transparent_account.json b/service/src/test/resources/fixtures/transparent_account.json deleted file mode 100644 index d2133ee23..000000000 --- a/service/src/test/resources/fixtures/transparent_account.json +++ /dev/null @@ -1 +0,0 @@ -{"devices":[{"id":1,"name":"foo","authToken":"bar","salt":"salt","signalingKey":"keykey","gcmId":"gcm-id","apnId":"apn-id","voipApnId":"voipapn-id","pushTimestamp":0,"uninstalledFeedback":0,"fetchesMessages":true,"registrationId":1234,"signedPreKey":{"keyId":5,"publicKey":"public-signed","signature":"signtture-signed"},"lastSeen":31337,"created":31336,"userAgent":"CoolClient","unauthenticatedDelivery":true}],"identityKey":"identity_key_value","name":"OneProfileName","avatar":null,"avatarDigest":null,"pin":"******","registrationLock":null, "registrationLockSalt":null,"uak":"AAAAAAAAAAAAAAAAAAAAAA==","uua":false} diff --git a/service/src/test/resources/fixtures/transparent_account2.json b/service/src/test/resources/fixtures/transparent_account2.json deleted file mode 100644 index c37801591..000000000 --- a/service/src/test/resources/fixtures/transparent_account2.json +++ /dev/null @@ -1 +0,0 @@ -{"devices":[{"id":1,"name":"2foo","authToken":"2bar","salt":"2salt","signalingKey":"2keykey","gcmId":"2gcm-id","apnId":"2apn-id","voipApnId":"2voipapn-id","pushTimestamp":0,"uninstalledFeedback":0,"fetchesMessages":true,"registrationId":1234,"signedPreKey":{"keyId":5,"publicKey":"public-signed","signature":"signtture-signed"},"lastSeen":31337,"created":31336,"userAgent":"CoolClient","unauthenticatedDelivery":true}],"identityKey":"different_identity_key_value","name":"TwoProfileName","avatar":null,"avatarDigest":null,"pin":"******","registrationLock":null,"registrationLockSalt":null,"uak":"AAAAAAAAAAAAAAAAAAAAAA==","uua":false} diff --git a/service/src/test/resources/logback-test.xml b/service/src/test/resources/logback-test.xml deleted file mode 100644 index b01f95f92..000000000 --- a/service/src/test/resources/logback-test.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - diff --git a/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker deleted file mode 100644 index 1f0955d45..000000000 --- a/service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker +++ /dev/null @@ -1 +0,0 @@ -mock-maker-inline diff --git a/service/src/test/resources/org/whispersystems/textsecuregcm/util/ip2asn-test.tsv b/service/src/test/resources/org/whispersystems/textsecuregcm/util/ip2asn-test.tsv deleted file mode 100644 index 08c4ce2ba..000000000 --- a/service/src/test/resources/org/whispersystems/textsecuregcm/util/ip2asn-test.tsv +++ /dev/null @@ -1,3 +0,0 @@ -95865344 95865855 0 None Not routed -458051584 458227711 7552 VN VIETEL-AS-AP Viettel Group -843841536 844103679 7922 US COMCAST-7922 - Comcast Cable Communications, LLC diff --git a/signal-server-openapi.yaml b/signal-server-openapi.yaml new file mode 100644 index 000000000..b423ade23 --- /dev/null +++ b/signal-server-openapi.yaml @@ -0,0 +1,3343 @@ +openapi: 3.0.1 +info: + title: Signal Server + license: + name: AGPL-3.0-only + url: https://www.gnu.org/licenses/agpl-3.0.txt +servers: +- url: https://chat.signal.org + description: Production service +- url: https://chat.staging.signal.org + description: Staging service +paths: + /v1/accounts/account/{uuid}: + head: + tags: + - Account + operationId: accountExists + parameters: + - name: uuid + in: path + required: true + schema: + type: string + format: uuid + responses: + default: + description: default response + content: + '*/*': {} + /v1/accounts/number: + put: + tags: + - Account + operationId: changeNumber + parameters: + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/ChangePhoneNumberRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AccountIdentityResponse' + security: + - authenticatedAccount: [] + /v1/accounts/username_hash/confirm: + put: + tags: + - Account + operationId: confirmUsernameHash + parameters: + - name: X-Signal-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmUsernameHashRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/UsernameHashResponse' + security: + - authenticatedAccount: [] + /v1/accounts/{transport}/code/{number}: + get: + tags: + - Account + operationId: createAccount + parameters: + - name: transport + in: path + required: true + schema: + type: string + - name: number + in: path + required: true + schema: + type: string + - name: X-Forwarded-For + in: header + schema: + type: string + - name: User-Agent + in: header + schema: + type: string + - name: Accept-Language + in: header + schema: + type: string + - name: client + in: query + schema: + type: string + - name: captcha + in: query + schema: + type: string + - name: challenge + in: query + schema: + type: string + responses: + default: + description: default response + content: + application/json: {} + /v1/accounts/me: + get: + tags: + - Account + operationId: getMe + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AccountIdentityResponse' + security: + - authenticatedAccount: [] + delete: + tags: + - Account + operationId: deleteAccount + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/accounts/apn: + put: + tags: + - Account + operationId: setApnRegistrationId + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ApnRegistrationId' + required: true + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + delete: + tags: + - Account + operationId: deleteApnRegistrationId + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/accounts/gcm: + put: + tags: + - Account + operationId: setGcmRegistrationId + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GcmRegistrationId' + required: true + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + delete: + tags: + - Account + operationId: deleteGcmRegistrationId + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/accounts/username_hash: + delete: + tags: + - Account + operationId: deleteUsernameHash + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/accounts/{type}/preauth/{token}/{number}: + get: + tags: + - Account + operationId: getPreAuth + parameters: + - name: type + in: path + required: true + schema: + type: string + - name: token + in: path + required: true + schema: + type: string + - name: number + in: path + required: true + schema: + type: string + - name: voip + in: query + schema: + type: boolean + default: true + responses: + default: + description: default response + content: + application/json: {} + /v1/accounts/turn: + get: + tags: + - Account + operationId: getTurnToken + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/TurnToken' + security: + - authenticatedAccount: [] + /v1/accounts/username_hash/{usernameHash}: + get: + tags: + - Account + operationId: lookupUsernameHash + parameters: + - name: X-Signal-Agent + in: header + schema: + type: string + - name: X-Forwarded-For + in: header + schema: + type: string + - name: usernameHash + in: path + required: true + schema: + type: string + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AccountIdentifierResponse' + /v1/accounts/registration_lock: + put: + tags: + - Account + operationId: setRegistrationLock + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/RegistrationLock' + required: true + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + delete: + tags: + - Account + operationId: removeRegistrationLock + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/accounts/signaling_key: + delete: + tags: + - Account + operationId: removeSignalingKey + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/accounts/username_hash/reserve: + put: + tags: + - Account + operationId: reserveUsernameHash + parameters: + - name: X-Signal-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ReserveUsernameHashRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/ReserveUsernameHashResponse' + security: + - authenticatedAccount: [] + /v1/accounts/attributes: + put: + tags: + - Account + operationId: setAccountAttributes + parameters: + - name: X-Signal-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AccountAttributes' + required: true + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/accounts/name: + put: + tags: + - Account + operationId: setName + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/DeviceName' + required: true + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/accounts/code/{verification_code}: + put: + tags: + - Account + operationId: verifyAccount + parameters: + - name: verification_code + in: path + required: true + schema: + type: string + - name: Authorization + in: header + schema: + $ref: '#/components/schemas/BasicAuthorizationHeader' + - name: X-Signal-Agent + in: header + schema: + type: string + - name: User-Agent + in: header + schema: + type: string + - name: transfer + in: query + schema: + type: boolean + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AccountAttributes' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AccountIdentityResponse' + /v1/accounts/whoami: + get: + tags: + - Account + operationId: whoAmI + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AccountIdentityResponse' + security: + - authenticatedAccount: [] + /v2/accounts/number: + put: + tags: + - Account + operationId: changeNumber_1 + parameters: + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChangeNumberRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AccountIdentityResponse' + security: + - authenticatedAccount: [] + /v2/accounts/phone_number_discoverability: + put: + tags: + - Account + operationId: setPhoneNumberDiscoverability + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PhoneNumberDiscoverabilityRequest' + required: true + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/art/auth: + get: + tags: + - Art + operationId: getAuth + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalServiceCredentials' + security: + - authenticatedAccount: [] + /v2/attachments/form/upload: + get: + tags: + - Attachments + operationId: getAttachmentUploadForm + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentDescriptorV2' + security: + - authenticatedAccount: [] + /v3/attachments/form/upload: + get: + tags: + - Attachments + operationId: getAttachmentUploadForm_1 + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentDescriptorV3' + security: + - authenticatedAccount: [] + /v1/certificate/group/{startRedemptionTime}/{endRedemptionTime}: + get: + tags: + - Certificate + operationId: getAuthenticationCredentials + parameters: + - name: startRedemptionTime + in: path + required: true + schema: + type: integer + format: int32 + - name: endRedemptionTime + in: path + required: true + schema: + type: integer + format: int32 + - name: identity + in: query + schema: + type: string + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/GroupCredentials' + deprecated: true + security: + - authenticatedAccount: [] + /v1/certificate/delivery: + get: + tags: + - Certificate + operationId: getDeliveryCertificate + parameters: + - name: includeE164 + in: query + schema: + type: boolean + default: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/DeliveryCertificate' + security: + - authenticatedAccount: [] + /v1/certificate/auth/group: + get: + tags: + - Certificate + operationId: getGroupAuthenticationCredentials + parameters: + - name: redemptionStartSeconds + in: query + schema: + type: integer + format: int32 + - name: redemptionEndSeconds + in: query + schema: + type: integer + format: int32 + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/GroupCredentials' + security: + - authenticatedAccount: [] + /v1/challenge: + put: + tags: + - Challenge + operationId: handleChallengeResponse + parameters: + - name: X-Forwarded-For + in: header + schema: + type: string + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AnswerChallengeRequest' + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/challenge/push: + post: + tags: + - Challenge + operationId: requestPushChallenge + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/devices/provisioning/code: + get: + tags: + - Devices + operationId: createDeviceToken + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationCode' + security: + - authenticatedAccount: [] + /v1/devices: + get: + tags: + - Devices + operationId: getDevices + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceInfoList' + security: + - authenticatedAccount: [] + /v1/devices/{device_id}: + delete: + tags: + - Devices + operationId: removeDevice + parameters: + - name: device_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/devices/capabilities: + put: + tags: + - Devices + operationId: setCapabiltities + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/DeviceCapabilities' + required: true + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/devices/unauthenticated_delivery: + put: + tags: + - Devices + operationId: setUnauthenticatedDelivery + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/devices/{verification_code}: + put: + tags: + - Devices + operationId: verifyDeviceToken + parameters: + - name: verification_code + in: path + required: true + schema: + type: string + - name: Authorization + in: header + schema: + $ref: '#/components/schemas/BasicAuthorizationHeader' + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AccountAttributes' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceResponse' + /v1/directory/auth: + get: + tags: + - Directory + operationId: getAuthToken + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/directory/tokens: + put: + tags: + - Directory + operationId: getContactIntersection + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/directory/{token}: + get: + tags: + - Directory + operationId: getTokenPresence + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/directory/feedback-v3/{status}: + put: + tags: + - Directory + operationId: setFeedback + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v2/directory/auth: + get: + tags: + - Directory + operationId: getAuthToken_1 + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/donation/redeem-receipt: + post: + tags: + - Donations + operationId: redeemReceipt + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RedeemReceiptRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + type: object + text/plain: + schema: + type: object + security: + - authenticatedAccount: [] + /v1/keepalive: + get: + tags: + - Keep Alive + operationId: getKeepAlive + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/WebSocketSessionContext' + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/keepalive/provisioning: + get: + tags: + - Keep Alive + operationId: getProvisioningKeepAlive + responses: + default: + description: default response + content: + '*/*': {} + /v2/keys/{identifier}/{device_id}: + get: + tags: + - Keys + operationId: getDeviceKeys + parameters: + - name: Unidentified-Access-Key + in: header + schema: + $ref: '#/components/schemas/Anonymous' + - name: identifier + in: path + required: true + schema: + type: string + format: uuid + - name: device_id + in: path + required: true + schema: + type: string + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: {} + /v2/keys/signed: + get: + tags: + - Keys + operationId: getSignedKey + parameters: + - name: identity + in: query + schema: + type: string + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/SignedPreKey' + security: + - authenticatedAccount: [] + put: + tags: + - Keys + operationId: setSignedKey + parameters: + - name: identity + in: query + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SignedPreKey' + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v2/keys: + get: + tags: + - Keys + operationId: getStatus + parameters: + - name: identity + in: query + schema: + type: string + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/PreKeyCount' + security: + - authenticatedAccount: [] + put: + tags: + - Keys + operationId: setKeys + parameters: + - name: identity + in: query + schema: + type: string + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PreKeyState' + required: true + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/messages: + get: + tags: + - Messages + operationId: getPendingMessages + parameters: + - name: X-Signal-Receive-Stories + in: header + schema: + type: string + - name: User-Agent + in: header + schema: + type: string + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + security: + - authenticatedAccount: [] + /v1/messages/uuid/{uuid}: + delete: + tags: + - Messages + operationId: removePendingMessage + parameters: + - name: uuid + in: path + required: true + schema: + type: string + format: uuid + responses: + default: + description: default response + content: + '*/*': + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + security: + - authenticatedAccount: [] + /v1/messages/report/{source}/{messageGuid}: + post: + tags: + - Messages + operationId: reportSpamMessage + parameters: + - name: source + in: path + required: true + schema: + type: string + - name: messageGuid + in: path + required: true + schema: + type: string + format: uuid + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SpamReport' + responses: + default: + description: default response + content: + '*/*': {} + security: + - authenticatedAccount: [] + /v1/messages/{destination}: + put: + tags: + - Messages + operationId: sendMessage + parameters: + - name: Unidentified-Access-Key + in: header + schema: + $ref: '#/components/schemas/Anonymous' + - name: User-Agent + in: header + schema: + type: string + - name: X-Forwarded-For + in: header + schema: + type: string + - name: destination + in: path + required: true + schema: + type: string + format: uuid + - name: story + in: query + schema: + type: boolean + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: {} + /v1/messages/multi_recipient: + put: + tags: + - Messages + operationId: sendMultiRecipientMessage + parameters: + - name: Unidentified-Access-Key + in: header + schema: + $ref: '#/components/schemas/CombinedUnidentifiedSenderAccessKeys' + - name: User-Agent + in: header + schema: + type: string + - name: X-Forwarded-For + in: header + schema: + type: string + - name: online + in: query + schema: + type: boolean + - name: ts + in: query + schema: + type: integer + format: int64 + - name: urgent + in: query + schema: + type: boolean + default: true + - name: story + in: query + schema: + type: boolean + requestBody: + content: + application/vnd.signal-messenger.mrm: + schema: + $ref: '#/components/schemas/MultiRecipientMessage' + required: true + responses: + default: + description: default response + content: + application/json: {} + /v1/payments/auth: + get: + tags: + - Payments + operationId: getAuth_1 + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalServiceCredentials' + security: + - authenticatedAccount: [] + /v1/payments/conversions: + get: + tags: + - Payments + operationId: getConversions + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/CurrencyConversionEntityList' + security: + - authenticatedAccount: [] + /v1/profile/{uuid}/{version}: + get: + tags: + - Profile + operationId: getProfile + parameters: + - name: Unidentified-Access-Key + in: header + schema: + $ref: '#/components/schemas/Anonymous' + - name: uuid + in: path + required: true + schema: + type: string + format: uuid + - name: version + in: path + required: true + schema: + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/VersionedProfileResponse' + /v1/profile/{uuid}/{version}/{credentialRequest}: + get: + tags: + - Profile + operationId: getProfile_1 + parameters: + - name: Unidentified-Access-Key + in: header + schema: + $ref: '#/components/schemas/Anonymous' + - name: uuid + in: path + required: true + schema: + type: string + format: uuid + - name: version + in: path + required: true + schema: + type: string + - name: credentialRequest + in: path + required: true + schema: + type: string + - name: credentialType + in: query + schema: + type: string + default: profileKey + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialProfileResponse' + /v1/profile/{identifier}: + get: + tags: + - Profile + operationId: getUnversionedProfile + parameters: + - name: Unidentified-Access-Key + in: header + schema: + $ref: '#/components/schemas/Anonymous' + - name: User-Agent + in: header + schema: + type: string + - name: identifier + in: path + required: true + schema: + type: string + format: uuid + - name: ca + in: query + schema: + type: boolean + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/BaseProfileResponse' + /v1/profile/identity_check/batch: + post: + tags: + - Profile + operationId: runBatchIdentityCheck + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BatchIdentityCheckRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/profile: + put: + tags: + - Profile + operationId: setProfile + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProfileRequest' + required: true + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/provisioning/{destination}: + put: + tags: + - Provisioning + operationId: sendProvisioningMessage + parameters: + - name: destination + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningMessage' + required: true + responses: + default: + description: default response + content: + application/json: {} + security: + - authenticatedAccount: [] + /v1/registration: + post: + tags: + - Registration + operationId: register + parameters: + - name: Authorization + in: header + required: true + schema: + $ref: '#/components/schemas/BasicAuthorizationHeader' + - name: X-Signal-Agent + in: header + schema: + type: string + - name: User-Agent + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrationRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/AccountIdentityResponse' + /v1/config/{name}: + delete: + tags: + - Remote Config + operationId: delete + parameters: + - name: Config-Token + in: header + schema: + type: string + - name: name + in: path + required: true + schema: + type: string + responses: + default: + description: default response + content: + '*/*': {} + /v1/config: + get: + tags: + - Remote Config + operationId: getAll + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/UserRemoteConfigList' + security: + - authenticatedAccount: [] + put: + tags: + - Remote Config + operationId: set + parameters: + - name: Config-Token + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RemoteConfig' + required: true + responses: + default: + description: default response + content: + application/json: {} + /v1/backup/auth/check: + post: + tags: + - Secure Value Recovery + summary: Check SVR credentials + description: | + Over time, clients may wind up with multiple sets of KBS authentication credentials in cloud storage. + To determine which set is most current and should be used to communicate with KBS to retrieve a master password + (from which a registration recovery password can be derived), clients should call this endpoint + with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with KBS. + operationId: authCheck + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthCheckRequest' + required: true + responses: + "200": + description: '`JSON` with the check results.' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthCheckResponse' + "422": + description: Provided list of KBS credentials could not be parsed + "400": + description: '`POST` request body is not a valid `JSON`' + /v1/backup/auth: + get: + tags: + - Secure Value Recovery + summary: Generate credentials for SVR + description: | + Generate SVR service credentials. Generated credentials have an expiration time of 30 days. + operationId: getAuth_2 + responses: + "200": + description: '`JSON` with generated credentials.' + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalServiceCredentials' + "401": + description: Account authentication check failed. + security: + - authenticatedAccount: [] + /v1/storage/auth: + get: + tags: + - Secure Storage + operationId: getAuth_3 + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalServiceCredentials' + security: + - authenticatedAccount: [] + /v2/backup/auth: + get: + tags: + - Secure Value Recovery + summary: Generate credentials for SVR2 + description: | + Generate SVR2 service credentials. Generated credentials have an expiration time of 30 days. + operationId: getAuth_4 + responses: + "200": + description: '`JSON` with generated credentials.' + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalServiceCredentials' + "401": + description: Account authentication check failed. + security: + - authenticatedAccount: [] + /v1/sticker/pack/form/{count}: + get: + tags: + - Stickers + operationId: getStickersForm + parameters: + - name: count + in: path + required: true + schema: + maximum: 201 + minimum: 1 + type: integer + format: int32 + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/StickerPackFormUploadAttributes' + security: + - authenticatedAccount: [] + /v1/subscription/boost/paypal/confirm: + post: + tags: + - Subscriptions + operationId: confirmPayPalBoost + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmPayPalBoostRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/boost/create: + post: + tags: + - Subscriptions + operationId: createBoostPaymentIntent + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateBoostRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/boost/receipt_credentials: + post: + tags: + - Subscriptions + operationId: createBoostReceiptCredentials + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateBoostReceiptCredentialsRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/boost/paypal/create: + post: + tags: + - Subscriptions + operationId: createPayPalBoost + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePayPalBoostRequest' + required: true + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/{subscriberId}/create_payment_method/paypal: + post: + tags: + - Subscriptions + operationId: createPayPalPaymentMethod + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/{subscriberId}/create_payment_method: + post: + tags: + - Subscriptions + operationId: createPaymentMethod + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + - name: type + in: query + schema: + type: string + default: CARD + enum: + - CARD + - PAYPAL + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/{subscriberId}/receipt_credentials: + post: + tags: + - Subscriptions + operationId: createSubscriptionReceiptCredentials + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/{subscriberId}: + get: + tags: + - Subscriptions + operationId: getSubscriptionInformation + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + put: + tags: + - Subscriptions + operationId: updateSubscriber + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + delete: + tags: + - Subscriptions + operationId: deleteSubscriber + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/boost/amounts: + get: + tags: + - Subscriptions + operationId: getBoostAmounts + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + deprecated: true + /v1/subscription/boost/badges: + get: + tags: + - Subscriptions + operationId: getBoostBadges + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + deprecated: true + /v1/subscription/configuration: + get: + tags: + - Subscriptions + operationId: getConfiguration + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/boost/amounts/gift: + get: + tags: + - Subscriptions + operationId: getGiftAmounts + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + deprecated: true + /v1/subscription/levels: + get: + tags: + - Subscriptions + operationId: getLevels + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + deprecated: true + /v1/subscription/{subscriberId}/default_payment_method/{paymentMethodId}: + post: + tags: + - Subscriptions + operationId: setDefaultPaymentMethod + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + - name: paymentMethodId + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + deprecated: true + /v1/subscription/{subscriberId}/default_payment_method/{processor}/{paymentMethodToken}: + post: + tags: + - Subscriptions + operationId: setDefaultPaymentMethodWithProcessor + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + - name: processor + in: path + required: true + schema: + type: string + enum: + - STRIPE + - BRAINTREE + - name: paymentMethodToken + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 + /v1/subscription/{subscriberId}/level/{level}/{currency}/{idempotencyKey}: + put: + tags: + - Subscriptions + operationId: setSubscriptionLevel + parameters: + - name: subscriberId + in: path + required: true + schema: + type: string + - name: level + in: path + required: true + schema: + type: integer + format: int64 + - name: currency + in: path + required: true + schema: + type: string + - name: idempotencyKey + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatedAccount' + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + cancelled: + type: boolean + done: + type: boolean + completedExceptionally: + type: boolean + numberOfDependents: + type: integer + format: int32 +components: + schemas: + AccountIdentityResponse: + type: object + properties: + uuid: + type: string + format: uuid + number: + type: string + pni: + type: string + format: uuid + usernameHash: + type: array + items: + type: string + format: byte + storageCapable: + type: boolean + ChangePhoneNumberRequest: + required: + - code + - number + type: object + properties: + reglock: + type: string + number: + type: string + code: + type: string + pniIdentityKey: + type: string + deviceMessages: + type: array + items: + $ref: '#/components/schemas/IncomingMessage' + devicePniSignedPrekeys: + type: object + additionalProperties: + $ref: '#/components/schemas/SignedPreKey' + pniRegistrationIds: + type: object + additionalProperties: + type: integer + format: int32 + IncomingMessage: + type: object + properties: + type: + type: integer + format: int32 + destinationDeviceId: + type: integer + format: int64 + destinationRegistrationId: + type: integer + format: int32 + content: + type: string + SignedPreKey: + required: + - keyId + - publicKey + - signature + type: object + properties: + keyId: + type: integer + format: int64 + publicKey: + type: string + signature: + type: string + UsernameHashResponse: + type: object + properties: + usernameHash: + type: array + items: + type: string + format: byte + ConfirmUsernameHashRequest: + type: object + properties: + usernameHash: + type: array + items: + type: string + format: byte + zkProof: + type: array + items: + type: string + format: byte + TurnToken: + type: object + properties: + username: + type: string + password: + type: string + urls: + type: array + items: + type: string + AccountIdentifierResponse: + required: + - uuid + type: object + properties: + uuid: + type: string + format: uuid + ReserveUsernameHashResponse: + type: object + properties: + usernameHash: + type: array + items: + type: string + format: byte + ReserveUsernameHashRequest: + required: + - usernameHashes + type: object + properties: + usernameHashes: + maxItems: 20 + minItems: 1 + type: array + items: + type: array + items: + type: string + format: byte + AccountAttributes: + type: object + properties: + fetchesMessages: + type: boolean + registrationId: + type: integer + format: int32 + name: + maxLength: 204 + minLength: 0 + type: string + registrationLock: + type: string + unidentifiedAccessKey: + type: array + items: + type: string + format: byte + unrestrictedUnidentifiedAccess: + type: boolean + capabilities: + $ref: '#/components/schemas/DeviceCapabilities' + discoverableByPhoneNumber: + type: boolean + recoveryPassword: + type: array + items: + type: string + format: byte + pniRegistrationId: + type: object + properties: + empty: + type: boolean + present: + type: boolean + asInt: + type: integer + format: int32 + DeviceCapabilities: + type: object + properties: + storage: + type: boolean + transfer: + type: boolean + senderKey: + type: boolean + announcementGroup: + type: boolean + changeNumber: + type: boolean + pni: + type: boolean + stories: + type: boolean + giftBadges: + type: boolean + paymentActivation: + type: boolean + ApnRegistrationId: + required: + - apnRegistrationId + type: object + properties: + apnRegistrationId: + type: string + voipRegistrationId: + type: string + GcmRegistrationId: + required: + - gcmRegistrationId + type: object + properties: + gcmRegistrationId: + type: string + DeviceName: + required: + - deviceName + type: object + properties: + deviceName: + maxLength: 300 + minLength: 0 + type: string + RegistrationLock: + required: + - registrationLock + type: object + properties: + registrationLock: + maxLength: 64 + minLength: 64 + type: string + BasicAuthorizationHeader: + type: object + properties: + username: + type: string + deviceId: + type: integer + format: int64 + password: + type: string + ChangeNumberRequest: + required: + - deviceMessages + - devicePniSignedPrekeys + - number + - pniIdentityKey + - pniRegistrationIds + type: object + properties: + reglock: + type: string + sessionId: + type: string + recoveryPassword: + type: array + items: + type: string + format: byte + number: + type: string + pniIdentityKey: + type: string + deviceMessages: + type: array + items: + $ref: '#/components/schemas/IncomingMessage' + devicePniSignedPrekeys: + type: object + additionalProperties: + $ref: '#/components/schemas/SignedPreKey' + pniRegistrationIds: + type: object + additionalProperties: + type: integer + format: int32 + valid: + type: boolean + PhoneNumberDiscoverabilityRequest: + required: + - discoverableByPhoneNumber + type: object + properties: + discoverableByPhoneNumber: + type: boolean + ExternalServiceCredentials: + type: object + properties: + username: + type: string + password: + type: string + AttachmentDescriptorV2: + type: object + properties: + key: + type: string + credential: + type: string + acl: + type: string + algorithm: + type: string + date: + type: string + policy: + type: string + signature: + type: string + attachmentId: + type: integer + format: int64 + attachmentIdString: + type: string + AttachmentDescriptorV3: + type: object + properties: + cdn: + type: integer + format: int32 + key: + type: string + headers: + type: object + additionalProperties: + type: string + signedUploadLocation: + type: string + GroupCredential: + type: object + properties: + credential: + type: array + items: + type: string + format: byte + redemptionTime: + type: integer + format: int64 + GroupCredentials: + type: object + properties: + credentials: + type: array + items: + $ref: '#/components/schemas/GroupCredential' + pni: + type: string + format: uuid + DeliveryCertificate: + type: object + properties: + certificate: + type: array + items: + type: string + format: byte + AnswerChallengeRequest: + required: + - type + type: object + properties: + type: + type: string + discriminator: + propertyName: type + AnswerPushChallengeRequest: + required: + - challenge + type: object + allOf: + - $ref: '#/components/schemas/AnswerChallengeRequest' + - type: object + properties: + challenge: + type: string + AnswerRecaptchaChallengeRequest: + required: + - captcha + - token + type: object + allOf: + - $ref: '#/components/schemas/AnswerChallengeRequest' + - type: object + properties: + token: + type: string + captcha: + type: string + VerificationCode: + type: object + properties: + verificationCode: + type: string + DeviceInfo: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + lastSeen: + type: integer + format: int64 + created: + type: integer + format: int64 + DeviceInfoList: + type: object + properties: + devices: + type: array + items: + $ref: '#/components/schemas/DeviceInfo' + DeviceResponse: + type: object + properties: + uuid: + type: string + format: uuid + pni: + type: string + format: uuid + deviceId: + type: integer + format: int64 + RedeemReceiptRequest: + required: + - receiptCredentialPresentation + type: object + properties: + receiptCredentialPresentation: + type: array + items: + type: string + format: byte + visible: + type: boolean + primary: + type: boolean + WebSocketClient: + type: object + properties: + open: + type: boolean + userAgent: + type: string + createdTimestamp: + type: integer + format: int64 + WebSocketSessionContext: + type: object + properties: + authenticated: + type: object + client: + $ref: '#/components/schemas/WebSocketClient' + Account: + type: object + properties: + number: + type: string + usernameHash: + type: array + items: + type: string + format: byte + reservedUsernameHash: + type: array + items: + type: string + format: byte + devices: + type: array + items: + $ref: '#/components/schemas/Device' + identityKey: + type: string + badges: + type: array + items: + $ref: '#/components/schemas/AccountBadge' + registrationLock: + $ref: '#/components/schemas/StoredRegistrationLock' + registrationLockSalt: + type: string + version: + type: integer + format: int32 + enabled: + type: boolean + masterDevice: + $ref: '#/components/schemas/Device' + transferSupported: + type: boolean + storageSupported: + type: boolean + lastSeen: + type: integer + format: int64 + pniSupported: + type: boolean + giftBadgesSupported: + type: boolean + nextDeviceId: + type: integer + format: int64 + senderKeySupported: + type: boolean + storiesSupported: + type: boolean + changeNumberSupported: + type: boolean + enabledDeviceCount: + type: integer + format: int32 + registrationLockFromAttributes: + $ref: '#/components/schemas/AccountAttributes' + paymentActivationSupported: + type: boolean + announcementGroupSupported: + type: boolean + pni: + type: string + format: uuid + pniIdentityKey: + type: string + cpv: + type: string + uak: + type: array + items: + type: string + format: byte + uua: + type: boolean + inCds: + type: boolean + AccountBadge: + type: object + properties: + id: + type: string + expiration: + type: string + format: date-time + visible: + type: boolean + AuthenticatedAccount: + type: object + properties: + name: + type: string + authenticatedDevice: + $ref: '#/components/schemas/Device' + account: + $ref: '#/components/schemas/Account' + Device: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + authToken: + type: string + salt: + type: string + gcmId: + type: string + apnId: + type: string + voipApnId: + type: string + pushTimestamp: + type: integer + format: int64 + uninstalledFeedback: + type: integer + format: int64 + fetchesMessages: + type: boolean + registrationId: + type: integer + format: int32 + signedPreKey: + $ref: '#/components/schemas/SignedPreKey' + lastSeen: + type: integer + format: int64 + created: + type: integer + format: int64 + userAgent: + type: string + capabilities: + $ref: '#/components/schemas/DeviceCapabilities' + enabled: + type: boolean + authTokenHash: + $ref: '#/components/schemas/SaltedTokenHash' + uninstalledFeedbackTimestamp: + type: integer + format: int64 + master: + type: boolean + pniRegistrationId: + type: object + properties: + empty: + type: boolean + present: + type: boolean + asInt: + type: integer + format: int32 + pniSignedPreKey: + $ref: '#/components/schemas/SignedPreKey' + SaltedTokenHash: + type: object + properties: + hash: + type: string + salt: + type: string + version: + type: string + enum: + - V1 + - V2 + StoredRegistrationLock: + type: object + properties: + timeRemaining: + type: integer + format: int64 + Anonymous: + type: object + properties: + accessKey: + type: array + items: + type: string + format: byte + PreKeyCount: + type: object + properties: + count: + type: integer + format: int32 + PreKey: + required: + - keyId + - publicKey + type: object + properties: + keyId: + type: integer + format: int64 + publicKey: + type: string + PreKeyState: + required: + - identityKey + - preKeys + - signedPreKey + type: object + properties: + preKeys: + type: array + items: + $ref: '#/components/schemas/PreKey' + signedPreKey: + $ref: '#/components/schemas/SignedPreKey' + identityKey: + type: string + SpamReport: + type: object + properties: + token: + type: array + items: + type: string + format: byte + IncomingMessageList: + required: + - messages + type: object + properties: + messages: + type: array + items: + $ref: '#/components/schemas/IncomingMessage' + online: + type: boolean + urgent: + type: boolean + timestamp: + type: integer + format: int64 + CombinedUnidentifiedSenderAccessKeys: + type: object + properties: + accessKeys: + type: array + items: + type: string + format: byte + MultiRecipientMessage: + required: + - commonPayload + - recipients + type: object + properties: + recipients: + maxItems: 5000 + minItems: 1 + type: array + items: + $ref: '#/components/schemas/Recipient' + commonPayload: + maxItems: 2147483647 + minItems: 32 + type: array + items: + type: string + format: byte + Recipient: + required: + - perRecipientKeyMaterial + - uuid + type: object + properties: + uuid: + type: string + format: uuid + deviceId: + minimum: 1 + type: integer + format: int64 + registrationId: + maximum: 65535 + minimum: 0 + type: integer + format: int32 + perRecipientKeyMaterial: + maxItems: 48 + minItems: 48 + type: array + items: + type: string + format: byte + CurrencyConversionEntity: + type: object + properties: + base: + type: string + conversions: + type: object + additionalProperties: + type: number + CurrencyConversionEntityList: + type: object + properties: + currencies: + type: array + items: + $ref: '#/components/schemas/CurrencyConversionEntity' + timestamp: + type: integer + format: int64 + Badge: + type: object + properties: + id: + type: string + category: + type: string + name: + type: string + description: + type: string + sprites6: + type: array + items: + type: string + svg: + type: string + svgs: + type: array + items: + $ref: '#/components/schemas/BadgeSvg' + imageUrl: + type: string + BadgeSvg: + required: + - dark + - light + type: object + properties: + light: + type: string + dark: + type: string + BaseProfileResponse: + type: object + properties: + identityKey: + type: string + unidentifiedAccess: + type: string + unrestrictedUnidentifiedAccess: + type: boolean + capabilities: + $ref: '#/components/schemas/UserCapabilities' + badges: + type: array + items: + $ref: '#/components/schemas/Badge' + uuid: + type: string + format: uuid + UserCapabilities: + type: object + properties: + gv1-migration: + type: boolean + senderKey: + type: boolean + announcementGroup: + type: boolean + changeNumber: + type: boolean + stories: + type: boolean + giftBadges: + type: boolean + paymentActivation: + type: boolean + pni: + type: boolean + VersionedProfileResponse: + type: object + properties: + identityKey: + type: string + unidentifiedAccess: + type: string + unrestrictedUnidentifiedAccess: + type: boolean + capabilities: + $ref: '#/components/schemas/UserCapabilities' + badges: + type: array + items: + $ref: '#/components/schemas/Badge' + uuid: + type: string + format: uuid + name: + type: string + about: + type: string + aboutEmoji: + type: string + avatar: + type: string + paymentAddress: + type: string + CredentialProfileResponse: + type: object + properties: + identityKey: + type: string + unidentifiedAccess: + type: string + unrestrictedUnidentifiedAccess: + type: boolean + capabilities: + $ref: '#/components/schemas/UserCapabilities' + badges: + type: array + items: + $ref: '#/components/schemas/Badge' + uuid: + type: string + format: uuid + name: + type: string + about: + type: string + aboutEmoji: + type: string + avatar: + type: string + paymentAddress: + type: string + BatchIdentityCheckRequest: + required: + - elements + type: object + properties: + elements: + maxItems: 1000 + minItems: 0 + type: array + items: + $ref: '#/components/schemas/Element' + Element: + required: + - fingerprint + type: object + properties: + aci: + type: string + format: uuid + uuid: + type: string + format: uuid + fingerprint: + type: array + items: + type: string + format: byte + CreateProfileRequest: + required: + - commitment + - version + type: object + properties: + version: + type: string + name: + type: string + avatar: + type: boolean + sameAvatar: + type: boolean + aboutEmoji: + type: string + about: + type: string + paymentAddress: + type: string + badgeIds: + type: array + items: + type: string + commitment: + $ref: '#/components/schemas/ProfileKeyCommitment' + avatarChange: + type: string + enum: + - UNCHANGED + - CLEAR + - UPDATE + badges: + type: array + items: + type: string + ProfileKeyCommitment: + type: object + properties: + internalContentsForJNI: + type: array + items: + type: string + format: byte + ProvisioningMessage: + required: + - body + type: object + properties: + body: + type: string + RegistrationRequest: + required: + - accountAttributes + type: object + properties: + sessionId: + type: string + recoveryPassword: + type: array + items: + type: string + format: byte + accountAttributes: + $ref: '#/components/schemas/AccountAttributes' + skipDeviceTransfer: + type: boolean + valid: + type: boolean + UserRemoteConfig: + type: object + properties: + name: + type: string + enabled: + type: boolean + value: + type: string + UserRemoteConfigList: + type: object + properties: + config: + type: array + items: + $ref: '#/components/schemas/UserRemoteConfig' + RemoteConfig: + required: + - percentage + - uuids + type: object + properties: + name: + pattern: "[A-Za-z0-9\\.]+" + type: string + percentage: + maximum: 100 + minimum: 0 + type: integer + format: int32 + uuids: + uniqueItems: true + type: array + items: + type: string + format: uuid + defaultValue: + type: string + value: + type: string + hashKey: + type: string + AuthCheckResponse: + required: + - matches + type: object + properties: + matches: + type: object + additionalProperties: + type: string + description: "A dictionary with the auth check results: `KBS Credentials\ + \ -> 'match'/'no-match'/'invalid'`" + enum: + - match + - no-match + - invalid + description: "A dictionary with the auth check results: `KBS Credentials\ + \ -> 'match'/'no-match'/'invalid'`" + AuthCheckRequest: + required: + - number + - passwords + type: object + properties: + number: + type: string + description: The e164-formatted phone number. + passwords: + maxItems: 10 + minItems: 0 + type: array + description: "A list of SVR auth values, previously retrieved from `/v1/backup/auth`;\ + \ may contain at most 10." + items: + type: string + description: "A list of SVR auth values, previously retrieved from `/v1/backup/auth`;\ + \ may contain at most 10." + StickerPackFormUploadAttributes: + type: object + properties: + manifest: + $ref: '#/components/schemas/StickerPackFormUploadItem' + stickers: + type: array + items: + $ref: '#/components/schemas/StickerPackFormUploadItem' + packId: + type: string + StickerPackFormUploadItem: + type: object + properties: + id: + type: integer + format: int32 + key: + type: string + credential: + type: string + acl: + type: string + algorithm: + type: string + date: + type: string + policy: + type: string + signature: + type: string + ConfirmPayPalBoostRequest: + required: + - currency + - payerId + - paymentId + - paymentToken + type: object + properties: + currency: + type: string + amount: + minimum: 1 + type: integer + format: int64 + level: + type: integer + format: int64 + payerId: + type: string + paymentId: + type: string + paymentToken: + type: string + CreateBoostRequest: + required: + - currency + type: object + properties: + currency: + type: string + amount: + minimum: 1 + type: integer + format: int64 + level: + type: integer + format: int64 + CreateBoostReceiptCredentialsRequest: + required: + - paymentIntentId + - processor + - receiptCredentialRequest + type: object + properties: + paymentIntentId: + type: string + receiptCredentialRequest: + type: array + items: + type: string + format: byte + processor: + type: string + enum: + - STRIPE + - BRAINTREE + CreatePayPalBoostRequest: + required: + - cancelUrl + - currency + - returnUrl + type: object + properties: + currency: + type: string + amount: + minimum: 1 + type: integer + format: int64 + level: + type: integer + format: int64 + returnUrl: + type: string + cancelUrl: + type: string + CreatePayPalBillingAgreementRequest: + required: + - cancelUrl + - returnUrl + type: object + properties: + returnUrl: + type: string + cancelUrl: + type: string + GetReceiptCredentialsRequest: + required: + - receiptCredentialRequest + type: object + properties: + receiptCredentialRequest: + type: array + items: + type: string + format: byte + securitySchemes: + authenticatedAccount: + type: http + description: "Account authentication is based on Basic authentication schema,\ + \ \nwhere `username` has a format of `[.]`. If `device_id`\ + \ is not specified,\nuser's `main` device is assumed.\n" + scheme: basic diff --git a/spam-filter b/spam-filter deleted file mode 160000 index e458e39a0..000000000 --- a/spam-filter +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e458e39a038c9cee3a5bee40133f446a9c687edb diff --git a/websocket-resources/pom.xml b/websocket-resources/pom.xml deleted file mode 100644 index 0857c9aa4..000000000 --- a/websocket-resources/pom.xml +++ /dev/null @@ -1,112 +0,0 @@ - - - - TextSecureServer - org.whispersystems.textsecure - JGITVER - - 4.0.0 - websocket-resources - - - - org.eclipse.jetty.websocket - websocket-api - - - org.eclipse.jetty.websocket - websocket-server - runtime - - - org.eclipse.jetty.websocket - websocket-servlet - - - io.dropwizard - dropwizard-core - - - io.dropwizard - dropwizard-auth - - - io.dropwizard - dropwizard-jersey - - - io.dropwizard - dropwizard-logging - - - org.glassfish.hk2.external - jakarta.inject - - - jakarta.validation - jakarta.validation-api - - - jakarta.ws.rs - jakarta.ws.rs-api - - - org.glassfish.jersey.core - jersey-common - - - org.glassfish.jersey.core - jersey-server - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-annotations - - - com.fasterxml.jackson.core - jackson-databind - - - ch.qos.logback - logback-core - - - ch.qos.logback - logback-classic - - - com.google.protobuf - protobuf-java - - - com.google.guava - guava - - - org.slf4j - slf4j-api - - - com.google.code.findbugs - jsr305 - - - - org.mockito - mockito-inline - test - - - org.junit.jupiter - junit-jupiter - test - - - - diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/Stories.java b/websocket-resources/src/main/java/org/whispersystems/websocket/Stories.java deleted file mode 100644 index 9cf859126..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/Stories.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.whispersystems.websocket; - -/** - * Class containing constants and shared logic for handling stories. - *

- * In particular, it defines the way we interpret the X-Signal-Receive-Stories header - * which is used by both WebSockets and by the REST API. - */ -public class Stories { - public final static String X_SIGNAL_RECEIVE_STORIES = "X-Signal-Receive-Stories"; - - public static boolean parseReceiveStoriesHeader(String s) { - return "true".equals(s); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketClient.java b/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketClient.java deleted file mode 100644 index a3dbd33d3..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketClient.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket; - -import com.google.common.net.HttpHeaders; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.security.SecureRandom; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import org.eclipse.jetty.websocket.api.RemoteEndpoint; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.WebSocketException; -import org.eclipse.jetty.websocket.api.WriteCallback; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.websocket.messages.WebSocketMessage; -import org.whispersystems.websocket.messages.WebSocketMessageFactory; -import org.whispersystems.websocket.messages.WebSocketResponseMessage; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class WebSocketClient { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketClient.class); - - private final Session session; - private final RemoteEndpoint remoteEndpoint; - private final WebSocketMessageFactory messageFactory; - private final Map> pendingRequestMapper; - private final long created; - - public WebSocketClient(Session session, RemoteEndpoint remoteEndpoint, - WebSocketMessageFactory messageFactory, - Map> pendingRequestMapper) { - this.session = session; - this.remoteEndpoint = remoteEndpoint; - this.messageFactory = messageFactory; - this.pendingRequestMapper = pendingRequestMapper; - this.created = System.currentTimeMillis(); - } - - public CompletableFuture sendRequest(String verb, String path, - List headers, - Optional body) - { - final long requestId = generateRequestId(); - final CompletableFuture future = new CompletableFuture<>(); - - pendingRequestMapper.put(requestId, future); - - WebSocketMessage requestMessage = messageFactory.createRequest(Optional.of(requestId), verb, path, headers, body); - - try { - remoteEndpoint.sendBytes(ByteBuffer.wrap(requestMessage.toByteArray()), new WriteCallback() { - @Override - public void writeFailed(Throwable x) { - logger.debug("Write failed", x); - pendingRequestMapper.remove(requestId); - future.completeExceptionally(x); - } - - @Override - public void writeSuccess() {} - }); - } catch (WebSocketException e) { - logger.debug("Write", e); - pendingRequestMapper.remove(requestId); - future.completeExceptionally(e); - } - - return future; - } - - public String getUserAgent() { - return session.getUpgradeRequest().getHeader(HttpHeaders.USER_AGENT); - } - - public long getCreatedTimestamp() { - return this.created; - } - - public boolean isOpen() { - return session.isOpen(); - } - - public void close(int code, String message) { - session.close(code, message); - } - - public boolean shouldDeliverStories() { - String value = session.getUpgradeRequest().getHeader(Stories.X_SIGNAL_RECEIVE_STORIES); - return Stories.parseReceiveStoriesHeader(value); - } - - public void hardDisconnectQuietly() { - try { - session.disconnect(); - } catch (IOException e) { - // quietly we said - } - } - - private long generateRequestId() { - return Math.abs(new SecureRandom().nextLong()); - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProvider.java b/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProvider.java deleted file mode 100644 index 12e37fba0..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProvider.java +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket; - -import com.google.common.annotations.VisibleForTesting; -import org.eclipse.jetty.websocket.api.RemoteEndpoint; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.WebSocketListener; -import org.glassfish.jersey.internal.MapPropertiesDelegate; -import org.glassfish.jersey.server.ApplicationHandler; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.ContainerResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.websocket.logging.WebsocketRequestLog; -import org.whispersystems.websocket.messages.InvalidMessageException; -import org.whispersystems.websocket.messages.WebSocketMessage; -import org.whispersystems.websocket.messages.WebSocketMessageFactory; -import org.whispersystems.websocket.messages.WebSocketRequestMessage; -import org.whispersystems.websocket.messages.WebSocketResponseMessage; -import org.whispersystems.websocket.session.ContextPrincipal; -import org.whispersystems.websocket.session.WebSocketSessionContext; -import org.whispersystems.websocket.setup.WebSocketConnectListener; - -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.net.URI; -import java.nio.ByteBuffer; -import java.security.Principal; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; - - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public class WebSocketResourceProvider implements WebSocketListener { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketResourceProvider.class); - - private final Map> requestMap = new ConcurrentHashMap<>(); - - private final T authenticated; - private final WebSocketMessageFactory messageFactory; - private final Optional connectListener; - private final ApplicationHandler jerseyHandler; - private final WebsocketRequestLog requestLog; - private final long idleTimeoutMillis; - private final String remoteAddress; - - private Session session; - private RemoteEndpoint remoteEndpoint; - private WebSocketSessionContext context; - - private static final Set EXCLUDED_UPGRADE_REQUEST_HEADERS = Set.of("connection", "upgrade"); - - public WebSocketResourceProvider(String remoteAddress, - ApplicationHandler jerseyHandler, - WebsocketRequestLog requestLog, - T authenticated, - WebSocketMessageFactory messageFactory, - Optional connectListener, - long idleTimeoutMillis) - { - this.remoteAddress = remoteAddress; - this.jerseyHandler = jerseyHandler; - this.requestLog = requestLog; - this.authenticated = authenticated; - this.messageFactory = messageFactory; - this.connectListener = connectListener; - this.idleTimeoutMillis = idleTimeoutMillis; - } - - @Override - public void onWebSocketConnect(Session session) { - this.session = session; - this.remoteEndpoint = session.getRemote(); - this.context = new WebSocketSessionContext(new WebSocketClient(session, remoteEndpoint, messageFactory, requestMap)); - this.context.setAuthenticated(authenticated); - this.session.setIdleTimeout(idleTimeoutMillis); - - connectListener.ifPresent(listener -> listener.onWebSocketConnect(this.context)); - } - - @Override - public void onWebSocketError(Throwable cause) { - logger.debug("onWebSocketError", cause); - close(session, 1011, "Server error"); - } - - @Override - public void onWebSocketBinary(byte[] payload, int offset, int length) { - try { - WebSocketMessage webSocketMessage = messageFactory.parseMessage(payload, offset, length); - - switch (webSocketMessage.getType()) { - case REQUEST_MESSAGE: - handleRequest(webSocketMessage.getRequestMessage()); - break; - case RESPONSE_MESSAGE: - handleResponse(webSocketMessage.getResponseMessage()); - break; - default: - close(session, 1018, "Badly formatted"); - break; - } - } catch (InvalidMessageException e) { - logger.debug("Parsing", e); - close(session, 1018, "Badly formatted"); - } - } - - @Override - public void onWebSocketClose(int statusCode, String reason) { - if (context != null) { - context.notifyClosed(statusCode, reason); - - for (long requestId : requestMap.keySet()) { - CompletableFuture outstandingRequest = requestMap.remove(requestId); - - if (outstandingRequest != null) { - outstandingRequest.completeExceptionally(new IOException("Connection closed!")); - } - } - } - } - - @Override - public void onWebSocketText(String message) { - logger.debug("onWebSocketText!"); - } - - private void handleRequest(WebSocketRequestMessage requestMessage) { - ContainerRequest containerRequest = new ContainerRequest(null, URI.create(requestMessage.getPath()), requestMessage.getVerb(), new WebSocketSecurityContext(new ContextPrincipal(context)), new MapPropertiesDelegate(new HashMap<>()), jerseyHandler.getConfiguration()); - containerRequest.headers(getCombinedHeaders(session.getUpgradeRequest().getHeaders(), requestMessage.getHeaders())); - - if (requestMessage.getBody().isPresent()) { - containerRequest.setEntityStream(new ByteArrayInputStream(requestMessage.getBody().get())); - } - - ByteArrayOutputStream responseBody = new ByteArrayOutputStream(); - CompletableFuture responseFuture = (CompletableFuture) jerseyHandler.apply(containerRequest, responseBody); - - responseFuture.thenAccept(response -> { - sendResponse(requestMessage, response, responseBody); - requestLog.log(remoteAddress, containerRequest, response); - }).exceptionally(exception -> { - logger.warn("Websocket Error: " + requestMessage.getVerb() + " " + requestMessage.getPath() + "\n" + requestMessage.getBody(), exception); - sendErrorResponse(requestMessage, Response.status(500).build()); - requestLog.log(remoteAddress, containerRequest, new ContainerResponse(containerRequest, Response.status(500).build())); - return null; - }); - } - - @VisibleForTesting - static Map> getCombinedHeaders(final Map> upgradeRequestHeaders, final Map requestMessageHeaders) { - final Map> combinedHeaders = new HashMap<>(); - - upgradeRequestHeaders.entrySet().stream() - .filter(entry -> shouldIncludeUpgradeRequestHeader(entry.getKey())) - .forEach(entry -> combinedHeaders.put(entry.getKey(), entry.getValue())); - - requestMessageHeaders.entrySet().stream() - .filter(entry -> shouldIncludeRequestMessageHeader(entry.getKey())) - .forEach(entry -> combinedHeaders.put(entry.getKey(), List.of(entry.getValue()))); - - return combinedHeaders; - } - - @VisibleForTesting - static boolean shouldIncludeUpgradeRequestHeader(final String header) { - return !EXCLUDED_UPGRADE_REQUEST_HEADERS.contains(header.toLowerCase()) && !header.toLowerCase().contains("websocket-"); - } - - @VisibleForTesting - static boolean shouldIncludeRequestMessageHeader(final String header) { - return !"X-Forwarded-For".equalsIgnoreCase(header.trim()); - } - - private void handleResponse(WebSocketResponseMessage responseMessage) { - CompletableFuture future = requestMap.remove(responseMessage.getRequestId()); - - if (future != null) { - future.complete(responseMessage); - } - } - - private void close(Session session, int status, String message) { - session.close(status, message); - } - - private void sendResponse(WebSocketRequestMessage requestMessage, ContainerResponse response, ByteArrayOutputStream responseBody) { - if (requestMessage.hasRequestId()) { - byte[] body = responseBody.toByteArray(); - - if (body.length <= 0) { - body = null; - } - - byte[] responseBytes = messageFactory.createResponse(requestMessage.getRequestId(), - response.getStatus(), - response.getStatusInfo().getReasonPhrase(), - getHeaderList(response.getStringHeaders()), - Optional.ofNullable(body)) - .toByteArray(); - - remoteEndpoint.sendBytesByFuture(ByteBuffer.wrap(responseBytes)); - } - } - - private void sendErrorResponse(WebSocketRequestMessage requestMessage, Response error) { - if (requestMessage.hasRequestId()) { - WebSocketMessage response = messageFactory.createResponse(requestMessage.getRequestId(), - error.getStatus(), - "Error response", - getHeaderList(error.getStringHeaders()), - Optional.empty()); - - remoteEndpoint.sendBytesByFuture(ByteBuffer.wrap(response.toByteArray())); - } - } - - - @VisibleForTesting - WebSocketSessionContext getContext() { - return context; - } - - @VisibleForTesting - static List getHeaderList(final MultivaluedMap headerMap) { - final List headers = new LinkedList<>(); - - if (headerMap != null) { - for (String key : headerMap.keySet()) { - headers.add(key + ":" + headerMap.getFirst(key)); - } - } - - return headers; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProviderFactory.java b/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProviderFactory.java deleted file mode 100644 index 8233e2346..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProviderFactory.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket; - -import static java.util.Optional.ofNullable; - -import io.dropwizard.jersey.jackson.JacksonMessageBodyProvider; -import java.io.IOException; -import java.security.Principal; -import java.util.Arrays; -import java.util.Optional; -import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; -import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; -import org.eclipse.jetty.websocket.servlet.WebSocketCreator; -import org.eclipse.jetty.websocket.servlet.WebSocketServlet; -import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; -import org.glassfish.jersey.server.ApplicationHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.whispersystems.websocket.auth.AuthenticationException; -import org.whispersystems.websocket.auth.WebSocketAuthenticator; -import org.whispersystems.websocket.auth.WebSocketAuthenticator.AuthenticationResult; -import org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider; -import org.whispersystems.websocket.configuration.WebSocketConfiguration; -import org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider; -import org.whispersystems.websocket.setup.WebSocketEnvironment; - -public class WebSocketResourceProviderFactory extends WebSocketServlet implements WebSocketCreator { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketResourceProviderFactory.class); - - private final WebSocketEnvironment environment; - private final ApplicationHandler jerseyApplicationHandler; - private final WebSocketConfiguration configuration; - - public WebSocketResourceProviderFactory(WebSocketEnvironment environment, Class principalClass, - WebSocketConfiguration configuration) { - this.environment = environment; - - environment.jersey().register(new WebSocketSessionContextValueFactoryProvider.Binder()); - environment.jersey().register(new WebsocketAuthValueFactoryProvider.Binder(principalClass)); - environment.jersey().register(new JacksonMessageBodyProvider(environment.getObjectMapper())); - - this.jerseyApplicationHandler = new ApplicationHandler(environment.jersey()); - - this.configuration = configuration; - } - - @Override - public Object createWebSocket(ServletUpgradeRequest request, ServletUpgradeResponse response) { - try { - Optional> authenticator = Optional.ofNullable(environment.getAuthenticator()); - T authenticated = null; - - if (authenticator.isPresent()) { - AuthenticationResult authenticationResult = authenticator.get().authenticate(request); - - if (authenticationResult.getUser().isEmpty() && authenticationResult.isRequired()) { - response.sendForbidden("Unauthorized"); - return null; - } else { - authenticated = authenticationResult.getUser().orElse(null); - } - } - - return new WebSocketResourceProvider<>(getRemoteAddress(request), - this.jerseyApplicationHandler, - this.environment.getRequestLog(), - authenticated, - this.environment.getMessageFactory(), - ofNullable(this.environment.getConnectListener()), - this.environment.getIdleTimeoutMillis()); - } catch (AuthenticationException | IOException e) { - logger.warn("Authentication failure", e); - try { - response.sendError(500, "Failure"); - } catch (IOException ignored) { - } - return null; - } - } - - @Override - public void configure(WebSocketServletFactory factory) { - factory.setCreator(this); - factory.getPolicy().setMaxBinaryMessageSize(configuration.getMaxBinaryMessageSize()); - factory.getPolicy().setMaxTextMessageSize(configuration.getMaxTextMessageSize()); - } - - private String getRemoteAddress(ServletUpgradeRequest request) { - String forwardedFor = request.getHeader("X-Forwarded-For"); - - if (forwardedFor == null || forwardedFor.isBlank()) { - return request.getRemoteAddress(); - } else { - return Arrays.stream(forwardedFor.split(",")) - .map(String::trim) - .reduce((a, b) -> b) - .orElseThrow(); - } - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketSecurityContext.java b/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketSecurityContext.java deleted file mode 100644 index f079cf2fa..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketSecurityContext.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket; - -import org.whispersystems.websocket.session.ContextPrincipal; -import org.whispersystems.websocket.session.WebSocketSessionContext; - -import javax.ws.rs.core.SecurityContext; -import java.security.Principal; - -public class WebSocketSecurityContext implements SecurityContext { - - private final ContextPrincipal principal; - - public WebSocketSecurityContext(ContextPrincipal principal) { - this.principal = principal; - } - - @Override - public Principal getUserPrincipal() { - return (Principal)principal.getContext().getAuthenticated(); - } - - @Override - public boolean isUserInRole(String role) { - return false; - } - - @Override - public boolean isSecure() { - return principal != null; - } - - @Override - public String getAuthenticationScheme() { - return null; - } - - public WebSocketSessionContext getSessionContext() { - return principal.getContext(); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/auth/AuthenticationException.java b/websocket-resources/src/main/java/org/whispersystems/websocket/auth/AuthenticationException.java deleted file mode 100644 index 447928ad1..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/auth/AuthenticationException.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.auth; - -public class AuthenticationException extends Exception { - - public AuthenticationException(String s) { - super(s); - } - - public AuthenticationException(Exception e) { - super(e); - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebSocketAuthenticator.java b/websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebSocketAuthenticator.java deleted file mode 100644 index f8d7969fb..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebSocketAuthenticator.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.auth; - -import org.eclipse.jetty.websocket.api.UpgradeRequest; - -import java.security.Principal; -import java.util.Optional; - -public interface WebSocketAuthenticator { - AuthenticationResult authenticate(UpgradeRequest request) throws AuthenticationException; - - @SuppressWarnings("OptionalUsedAsFieldOrParameterType") - public class AuthenticationResult { - private final Optional user; - private final boolean required; - - public AuthenticationResult(Optional user, boolean required) { - this.user = user; - this.required = required; - } - - public Optional getUser() { - return user; - } - - public boolean isRequired() { - return required; - } - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebsocketAuthValueFactoryProvider.java b/websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebsocketAuthValueFactoryProvider.java deleted file mode 100644 index 28c488ea3..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebsocketAuthValueFactoryProvider.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.auth; - -import io.dropwizard.auth.Auth; -import org.glassfish.jersey.internal.inject.AbstractBinder; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider; -import org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider; -import org.glassfish.jersey.server.model.Parameter; -import org.glassfish.jersey.server.spi.internal.ValueParamProvider; - -import javax.annotation.Nullable; -import javax.inject.Inject; -import javax.inject.Singleton; -import javax.ws.rs.WebApplicationException; -import java.lang.reflect.ParameterizedType; -import java.security.Principal; -import java.util.Optional; -import java.util.function.Function; - -@Singleton -public class WebsocketAuthValueFactoryProvider extends AbstractValueParamProvider { - - private final Class principalClass; - - @Inject - public WebsocketAuthValueFactoryProvider(MultivaluedParameterExtractorProvider mpep, WebsocketPrincipalClassProvider principalClassProvider) { - super(() -> mpep, Parameter.Source.UNKNOWN); - this.principalClass = principalClassProvider.clazz; - } - - @Nullable - @Override - protected Function createValueProvider(Parameter parameter) { - if (!parameter.isAnnotationPresent(Auth.class)) { - return null; - } - - if (parameter.getRawType() == Optional.class && - ParameterizedType.class.isAssignableFrom(parameter.getType().getClass()) && - principalClass == ((ParameterizedType)parameter.getType()).getActualTypeArguments()[0]) - { - return request -> new OptionalContainerRequestValueFactory(request).provide(); - } else if (principalClass.equals(parameter.getRawType())) { - return request -> new StandardContainerRequestValueFactory(request).provide(); - } else { - throw new IllegalStateException("Can't inject unassignable principal: " + principalClass + " for parameter: " + parameter); - } - } - - @Singleton - static class WebsocketPrincipalClassProvider { - - private final Class clazz; - - WebsocketPrincipalClassProvider(Class clazz) { - this.clazz = clazz; - } - } - - /** - * Injection binder for {@link io.dropwizard.auth.AuthValueFactoryProvider}. - * - * @param the type of the principal - */ - public static class Binder extends AbstractBinder { - - private final Class principalClass; - - public Binder(Class principalClass) { - this.principalClass = principalClass; - } - - @Override - protected void configure() { - bind(new WebsocketPrincipalClassProvider<>(principalClass)).to(WebsocketPrincipalClassProvider.class); - bind(WebsocketAuthValueFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class); - } - } - - private static class StandardContainerRequestValueFactory { - - private final ContainerRequest request; - - public StandardContainerRequestValueFactory(ContainerRequest request) { - this.request = request; - } - - public Principal provide() { - final Principal principal = request.getSecurityContext().getUserPrincipal(); - - if (principal == null) { - throw new WebApplicationException("Authenticated resource", 401); - } - - return principal; - } - - } - - private static class OptionalContainerRequestValueFactory { - - private final ContainerRequest request; - - public OptionalContainerRequestValueFactory(ContainerRequest request) { - this.request = request; - } - - public Optional provide() { - return Optional.ofNullable(request.getSecurityContext().getUserPrincipal()); - } - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/configuration/WebSocketConfiguration.java b/websocket-resources/src/main/java/org/whispersystems/websocket/configuration/WebSocketConfiguration.java deleted file mode 100644 index fb8c8a957..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/configuration/WebSocketConfiguration.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.configuration; - -import com.fasterxml.jackson.annotation.JsonProperty; -import javax.validation.Valid; -import javax.validation.constraints.Max; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotNull; -import org.whispersystems.websocket.logging.WebsocketRequestLoggerFactory; - -public class WebSocketConfiguration { - - @Valid - @NotNull - @JsonProperty - private WebsocketRequestLoggerFactory requestLog = new WebsocketRequestLoggerFactory(); - - @Min(512 * 1024) // 512 KB - @Max(10 * 1024 * 1024) // 10 MB - @JsonProperty - private int maxBinaryMessageSize = 512 * 1024; - - @Min(512 * 1024) // 512 KB - @Max(10 * 1024 * 1024) // 10 MB - @JsonProperty - private int maxTextMessageSize = 512 * 1024; - - public WebsocketRequestLoggerFactory getRequestLog() { - return requestLog; - } - - public int getMaxBinaryMessageSize() { - return maxBinaryMessageSize; - } - - public int getMaxTextMessageSize() { - return maxTextMessageSize; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/AsyncWebsocketEventAppenderFactory.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/AsyncWebsocketEventAppenderFactory.java deleted file mode 100644 index 2d0399de7..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/AsyncWebsocketEventAppenderFactory.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging; - -import ch.qos.logback.core.AsyncAppenderBase; -import io.dropwizard.logging.async.AsyncAppenderFactory; - -public class AsyncWebsocketEventAppenderFactory implements AsyncAppenderFactory { - @Override - public AsyncAppenderBase build() { - return new AsyncAppenderBase() { - @Override - protected void preprocess(WebsocketEvent event) { - event.prepareForDeferredProcessing(); - } - }; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketEvent.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketEvent.java deleted file mode 100644 index 29cd5b098..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketEvent.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging; - -import ch.qos.logback.core.spi.DeferredProcessingAware; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.ContainerResponse; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.List; - -public class WebsocketEvent implements DeferredProcessingAware { - - public static final int SENTINEL = -1; - public static final String NA = "-"; - - private final String remoteAddress; - private final ContainerRequest request; - private final ContainerResponse response; - private final long timestamp; - - public WebsocketEvent(String remoteAddress, ContainerRequest jerseyRequest, ContainerResponse jettyResponse) { - this.timestamp = System.currentTimeMillis(); - this.remoteAddress = remoteAddress; - this.request = jerseyRequest; - this.response = jettyResponse; - } - - public String getRemoteHost() { - return remoteAddress; - } - - public long getTimestamp() { - return timestamp; - } - - @Override - public void prepareForDeferredProcessing() { - - } - - public String getMethod() { - return request.getMethod(); - } - - public String getPath() { - return request.getBaseUri().getPath() + request.getPath(false); - } - - public String getProtocol() { - return "WS"; - } - - public int getStatusCode() { - return response.getStatus(); - } - - public long getContentLength() { - return response.getLength(); - } - - public String getRequestHeader(String key) { - List values = request.getRequestHeader(key); - - if (values == null) return NA; - else return values.stream().findFirst().orElse(NA); - } - - public MultivaluedMap getRequestHeaderMap() { - return request.getRequestHeaders(); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLog.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLog.java deleted file mode 100644 index 8fbac2f27..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLog.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging; - -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.ContainerResponse; - -import ch.qos.logback.core.Appender; -import ch.qos.logback.core.filter.Filter; -import ch.qos.logback.core.spi.AppenderAttachableImpl; -import ch.qos.logback.core.spi.FilterAttachableImpl; -import ch.qos.logback.core.spi.FilterReply; - -public class WebsocketRequestLog { - - private final AppenderAttachableImpl aai = new AppenderAttachableImpl<>(); - private final FilterAttachableImpl fai = new FilterAttachableImpl<>(); - - public WebsocketRequestLog() { - } - - public void log(String remoteAddress, ContainerRequest jerseyRequest, ContainerResponse jettyResponse) { - WebsocketEvent event = new WebsocketEvent(remoteAddress, jerseyRequest, jettyResponse); - - if (getFilterChainDecision(event) == FilterReply.DENY) { - return; - } - - aai.appendLoopOnAppenders(event); - } - - - public void addAppender(Appender newAppender) { - aai.addAppender(newAppender); - } - - public void addFilter(Filter newFilter) { - fai.addFilter(newFilter); - } - - public FilterReply getFilterChainDecision(WebsocketEvent event) { - return fai.getFilterChainDecision(event); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLoggerFactory.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLoggerFactory.java deleted file mode 100644 index 697739394..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLoggerFactory.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging; - -import com.google.common.annotations.VisibleForTesting; -import org.slf4j.LoggerFactory; -import org.whispersystems.websocket.logging.layout.WebsocketEventLayoutFactory; - -import javax.validation.Valid; -import javax.validation.constraints.NotNull; -import java.util.Collections; -import java.util.List; - -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import io.dropwizard.logging.AppenderFactory; -import io.dropwizard.logging.ConsoleAppenderFactory; -import io.dropwizard.logging.async.AsyncAppenderFactory; -import io.dropwizard.logging.filter.LevelFilterFactory; -import io.dropwizard.logging.filter.NullLevelFilterFactory; -import io.dropwizard.logging.layout.LayoutFactory; - -public class WebsocketRequestLoggerFactory { - - @VisibleForTesting - @Valid - @NotNull - public List> appenders = Collections.singletonList(new ConsoleAppenderFactory<>()); - - public WebsocketRequestLog build(String name) { - final Logger logger = (Logger) LoggerFactory.getLogger("websocket.request"); - logger.setAdditive(false); - - final LoggerContext context = logger.getLoggerContext(); - final WebsocketRequestLog requestLog = new WebsocketRequestLog(); - final LevelFilterFactory levelFilterFactory = new NullLevelFilterFactory<>(); - final AsyncAppenderFactory asyncAppenderFactory = new AsyncWebsocketEventAppenderFactory(); - final LayoutFactory layoutFactory = new WebsocketEventLayoutFactory(); - - for (AppenderFactory output : appenders) { - requestLog.addAppender(output.build(context, name, layoutFactory, levelFilterFactory, asyncAppenderFactory)); - } - - return requestLog; - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayout.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayout.java deleted file mode 100644 index d42916b98..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayout.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout; - -import ch.qos.logback.core.Context; -import ch.qos.logback.core.pattern.PatternLayoutBase; -import org.whispersystems.websocket.logging.WebsocketEvent; -import org.whispersystems.websocket.logging.layout.converters.ContentLengthConverter; -import org.whispersystems.websocket.logging.layout.converters.DateConverter; -import org.whispersystems.websocket.logging.layout.converters.EnsureLineSeparation; -import org.whispersystems.websocket.logging.layout.converters.NAConverter; -import org.whispersystems.websocket.logging.layout.converters.RemoteHostConverter; -import org.whispersystems.websocket.logging.layout.converters.RequestHeaderConverter; -import org.whispersystems.websocket.logging.layout.converters.RequestUrlConverter; -import org.whispersystems.websocket.logging.layout.converters.StatusCodeConverter; - -import java.util.HashMap; -import java.util.Map; - -public class WebsocketEventLayout extends PatternLayoutBase { - - private static final Map DEFAULT_CONVERTERS = new HashMap<>() {{ - put("h", RemoteHostConverter.class.getName()); - put("l", NAConverter.class.getName()); - put("u", NAConverter.class.getName()); - put("t", DateConverter.class.getName()); - put("r", RequestUrlConverter.class.getName()); - put("s", StatusCodeConverter.class.getName()); - put("b", ContentLengthConverter.class.getName()); - put("i", RequestHeaderConverter.class.getName()); - }}; - - public static final String CLF_PATTERN = "%h %l %u [%t] \"%r\" %s %b"; - public static final String CLF_PATTERN_NAME = "common"; - public static final String CLF_PATTERN_NAME_2 = "clf"; - public static final String COMBINED_PATTERN = "%h %l %u [%t] \"%r\" %s %b \"%i{Referer}\" \"%i{User-Agent}\""; - public static final String COMBINED_PATTERN_NAME = "combined"; - public static final String HEADER_PREFIX = "#logback.access pattern: "; - - public WebsocketEventLayout(Context context) { - setOutputPatternAsHeader(false); - setPattern(COMBINED_PATTERN); - setContext(context); - - this.postCompileProcessor = new EnsureLineSeparation(); - } - - @Override - public Map getDefaultConverterMap() { - return DEFAULT_CONVERTERS; - } - - @Override - public String doLayout(WebsocketEvent event) { - if (!isStarted()) { - return null; - } - - return writeLoopOnConverters(event); - } - - @Override - public void start() { - if (getPattern().equalsIgnoreCase(CLF_PATTERN_NAME) || getPattern().equalsIgnoreCase(CLF_PATTERN_NAME_2)) { - setPattern(CLF_PATTERN); - } else if (getPattern().equalsIgnoreCase(COMBINED_PATTERN_NAME)) { - setPattern(COMBINED_PATTERN); - } - - super.start(); - } - - @Override - protected String getPresentationHeaderPrefix() { - return HEADER_PREFIX; - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayoutFactory.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayoutFactory.java deleted file mode 100644 index 8013d38dc..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayoutFactory.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.core.pattern.PatternLayoutBase; -import io.dropwizard.logging.layout.LayoutFactory; -import org.whispersystems.websocket.logging.WebsocketEvent; - -import java.util.TimeZone; - -public class WebsocketEventLayoutFactory implements LayoutFactory { - @Override - public PatternLayoutBase build(LoggerContext context, TimeZone timeZone) { - return new WebsocketEventLayout(context); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/ContentLengthConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/ContentLengthConverter.java deleted file mode 100644 index 46187a2d0..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/ContentLengthConverter.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -public class ContentLengthConverter extends WebSocketEventConverter { - @Override - public String convert(WebsocketEvent event) { - if (event.getContentLength() == WebsocketEvent.SENTINEL) { - return WebsocketEvent.NA; - } else { - return Long.toString(event.getContentLength()); - } - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/DateConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/DateConverter.java deleted file mode 100644 index abbf27cea..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/DateConverter.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import ch.qos.logback.core.CoreConstants; -import ch.qos.logback.core.util.CachingDateFormatter; -import org.whispersystems.websocket.logging.WebsocketEvent; - -import java.util.List; -import java.util.TimeZone; - -public class DateConverter extends WebSocketEventConverter { - - private CachingDateFormatter cachingDateFormatter = null; - - @Override - public void start() { - - String datePattern = getFirstOption(); - if (datePattern == null) { - datePattern = CoreConstants.CLF_DATE_PATTERN; - } - - if (datePattern.equals(CoreConstants.ISO8601_STR)) { - datePattern = CoreConstants.ISO8601_PATTERN; - } - - try { - cachingDateFormatter = new CachingDateFormatter(datePattern); - // maximumCacheValidity = CachedDateFormat.getMaximumCacheValidity(pattern); - } catch (IllegalArgumentException e) { - addWarn("Could not instantiate SimpleDateFormat with pattern " + datePattern, e); - addWarn("Defaulting to " + CoreConstants.CLF_DATE_PATTERN); - cachingDateFormatter = new CachingDateFormatter(CoreConstants.CLF_DATE_PATTERN); - } - - List optionList = getOptionList(); - - // if the option list contains a TZ option, then set it. - if (optionList != null && optionList.size() > 1) { - TimeZone tz = TimeZone.getTimeZone((String) optionList.get(1)); - cachingDateFormatter.setTimeZone(tz); - } - } - - @Override - public String convert(WebsocketEvent websocketEvent) { - long timestamp = websocketEvent.getTimestamp(); - return cachingDateFormatter.format(timestamp); - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/EnsureLineSeparation.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/EnsureLineSeparation.java deleted file mode 100644 index 59322e154..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/EnsureLineSeparation.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -import ch.qos.logback.core.Context; -import ch.qos.logback.core.pattern.Converter; -import ch.qos.logback.core.pattern.ConverterUtil; -import ch.qos.logback.core.pattern.PostCompileProcessor; - -public class EnsureLineSeparation implements PostCompileProcessor { - - /** - * Add a line separator converter so that access event appears on a separate - * line. - */ - @Override - public void process(Context context, Converter head) { - if (head == null) - throw new IllegalArgumentException("Empty converter chain"); - - // if head != null, then tail != null as well - Converter tail = ConverterUtil.findTail(head); - Converter newLineConverter = new LineSeparatorConverter(); - - if (!(tail instanceof LineSeparatorConverter)) { - tail.setNext(newLineConverter); - } - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/LineSeparatorConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/LineSeparatorConverter.java deleted file mode 100644 index d1860b392..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/LineSeparatorConverter.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -import ch.qos.logback.core.CoreConstants; - -public class LineSeparatorConverter extends WebSocketEventConverter { - public LineSeparatorConverter() { - } - - public String convert(WebsocketEvent event) { - return CoreConstants.LINE_SEPARATOR; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/NAConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/NAConverter.java deleted file mode 100644 index e83eb2636..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/NAConverter.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -public class NAConverter extends WebSocketEventConverter { - @Override - public String convert(WebsocketEvent event) { - return WebsocketEvent.NA; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RemoteHostConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RemoteHostConverter.java deleted file mode 100644 index 5862faacf..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RemoteHostConverter.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -public class RemoteHostConverter extends WebSocketEventConverter { - @Override - public String convert(WebsocketEvent event) { - return event.getRemoteHost(); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestHeaderConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestHeaderConverter.java deleted file mode 100644 index 7a10466d2..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestHeaderConverter.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -import ch.qos.logback.core.util.OptionHelper; - -public class RequestHeaderConverter extends WebSocketEventConverter { - - private String key; - - @Override - public void start() { - key = getFirstOption(); - if (OptionHelper.isEmpty(key)) { - addWarn("Missing key for the requested header. Defaulting to all keys."); - key = null; - } - super.start(); - } - - @Override - public String convert(WebsocketEvent websocketEvent) { - if (!isStarted()) { - return "INACTIVE_HEADER_CONV"; - } - - if (key != null) { - return websocketEvent.getRequestHeader(key); - } else { - return websocketEvent.getRequestHeaderMap().toString(); - } - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestUrlConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestUrlConverter.java deleted file mode 100644 index f74f2d9be..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestUrlConverter.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -public class RequestUrlConverter extends WebSocketEventConverter { - @Override - public String convert(WebsocketEvent event) { - return - event.getMethod() + - WebSocketEventConverter.SPACE_CHAR + - event.getPath() + - WebSocketEventConverter.SPACE_CHAR + - event.getProtocol(); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/StatusCodeConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/StatusCodeConverter.java deleted file mode 100644 index 903f923ac..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/StatusCodeConverter.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -public class StatusCodeConverter extends WebSocketEventConverter { - @Override - public String convert(WebsocketEvent event) { - if (event.getStatusCode() == WebsocketEvent.SENTINEL) { - return WebsocketEvent.NA; - } else { - return Integer.toString(event.getStatusCode()); - } - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/WebSocketEventConverter.java b/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/WebSocketEventConverter.java deleted file mode 100644 index 979600185..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/WebSocketEventConverter.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging.layout.converters; - -import org.whispersystems.websocket.logging.WebsocketEvent; - -import ch.qos.logback.core.Context; -import ch.qos.logback.core.pattern.DynamicConverter; -import ch.qos.logback.core.spi.ContextAware; -import ch.qos.logback.core.spi.ContextAwareBase; -import ch.qos.logback.core.status.Status; - -public abstract class WebSocketEventConverter extends DynamicConverter implements ContextAware { - - public final static char SPACE_CHAR = ' '; - public final static char QUESTION_CHAR = '?'; - - ContextAwareBase cab = new ContextAwareBase(); - - @Override - public void setContext(Context context) { - cab.setContext(context); - } - - @Override - public Context getContext() { - return cab.getContext(); - } - - @Override - public void addStatus(Status status) { - cab.addStatus(status); - } - - @Override - public void addInfo(String msg) { - cab.addInfo(msg); - } - - @Override - public void addInfo(String msg, Throwable ex) { - cab.addInfo(msg, ex); - } - - @Override - public void addWarn(String msg) { - cab.addWarn(msg); - } - - @Override - public void addWarn(String msg, Throwable ex) { - cab.addWarn(msg, ex); - } - - @Override - public void addError(String msg) { - cab.addError(msg); - } - - @Override - public void addError(String msg, Throwable ex) { - cab.addError(msg, ex); - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/InvalidMessageException.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/InvalidMessageException.java deleted file mode 100644 index 027634ff4..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/InvalidMessageException.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages; - -public class InvalidMessageException extends Exception { - public InvalidMessageException(String s) { - super(s); - } - - public InvalidMessageException(Exception e) { - super(e); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessage.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessage.java deleted file mode 100644 index 4cf41f89f..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessage.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages; - -public interface WebSocketMessage { - - public enum Type { - UNKNOWN_MESSAGE, - REQUEST_MESSAGE, - RESPONSE_MESSAGE - } - - public Type getType(); - public WebSocketRequestMessage getRequestMessage(); - public WebSocketResponseMessage getResponseMessage(); - public byte[] toByteArray(); - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessageFactory.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessageFactory.java deleted file mode 100644 index d24a7e09b..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessageFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages; - - -import java.util.List; -import java.util.Optional; - -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -public interface WebSocketMessageFactory { - - public WebSocketMessage parseMessage(byte[] serialized, int offset, int len) - throws InvalidMessageException; - - public WebSocketMessage createRequest(Optional requestId, - String verb, String path, - List headers, - Optional body); - - public WebSocketMessage createResponse(long requestId, int status, String message, - List headers, - Optional body); - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketRequestMessage.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketRequestMessage.java deleted file mode 100644 index c5cd94483..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketRequestMessage.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages; - -import java.util.Map; -import java.util.Optional; - -public interface WebSocketRequestMessage { - - public String getVerb(); - public String getPath(); - public Map getHeaders(); - public Optional getBody(); - public long getRequestId(); - public boolean hasRequestId(); - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketResponseMessage.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketResponseMessage.java deleted file mode 100644 index 38e6bacfa..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketResponseMessage.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages; - - -import java.util.Map; -import java.util.Optional; - -public interface WebSocketResponseMessage { - public long getRequestId(); - public int getStatus(); - public String getMessage(); - public Map getHeaders(); - public Optional getBody(); -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessage.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessage.java deleted file mode 100644 index 909673363..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessage.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages.protobuf; - -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; -import org.whispersystems.websocket.messages.InvalidMessageException; -import org.whispersystems.websocket.messages.WebSocketMessage; -import org.whispersystems.websocket.messages.WebSocketRequestMessage; -import org.whispersystems.websocket.messages.WebSocketResponseMessage; - -public class ProtobufWebSocketMessage implements WebSocketMessage { - - private final SubProtocol.WebSocketMessage message; - - ProtobufWebSocketMessage(byte[] buffer, int offset, int length) throws InvalidMessageException { - try { - this.message = SubProtocol.WebSocketMessage.parseFrom(ByteString.copyFrom(buffer, offset, length)); - - if (getType() == Type.REQUEST_MESSAGE) { - if (!message.getRequest().hasVerb() || !message.getRequest().hasPath()) { - throw new InvalidMessageException("Missing required request attributes!"); - } - } else if (getType() == Type.RESPONSE_MESSAGE) { - if (!message.getResponse().hasId() || !message.getResponse().hasStatus() || !message.getResponse().hasMessage()) { - throw new InvalidMessageException("Missing required response attributes!"); - } - } - } catch (InvalidProtocolBufferException e) { - throw new InvalidMessageException(e); - } - } - - ProtobufWebSocketMessage(SubProtocol.WebSocketMessage message) { - this.message = message; - } - - @Override - public Type getType() { - if (message.getType().getNumber() == SubProtocol.WebSocketMessage.Type.REQUEST_VALUE && - message.hasRequest()) - { - return Type.REQUEST_MESSAGE; - } else if (message.getType().getNumber() == SubProtocol.WebSocketMessage.Type.RESPONSE_VALUE && - message.hasResponse()) - { - return Type.RESPONSE_MESSAGE; - } else { - return Type.UNKNOWN_MESSAGE; - } - } - - @Override - public WebSocketRequestMessage getRequestMessage() { - return new ProtobufWebSocketRequestMessage(message.getRequest()); - } - - @Override - public WebSocketResponseMessage getResponseMessage() { - return new ProtobufWebSocketResponseMessage(message.getResponse()); - } - - @Override - public byte[] toByteArray() { - return message.toByteArray(); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessageFactory.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessageFactory.java deleted file mode 100644 index d4f25bd89..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessageFactory.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages.protobuf; - -import com.google.protobuf.ByteString; -import org.whispersystems.websocket.messages.InvalidMessageException; -import org.whispersystems.websocket.messages.WebSocketMessage; -import org.whispersystems.websocket.messages.WebSocketMessageFactory; - -import java.util.List; -import java.util.Optional; - -public class ProtobufWebSocketMessageFactory implements WebSocketMessageFactory { - - @Override - public WebSocketMessage parseMessage(byte[] serialized, int offset, int len) - throws InvalidMessageException - { - return new ProtobufWebSocketMessage(serialized, offset, len); - } - - @Override - public WebSocketMessage createRequest(Optional requestId, - String verb, String path, - List headers, - Optional body) - { - SubProtocol.WebSocketRequestMessage.Builder requestMessage = - SubProtocol.WebSocketRequestMessage.newBuilder() - .setVerb(verb) - .setPath(path); - - if (requestId.isPresent()) { - requestMessage.setId(requestId.get()); - } - - if (body.isPresent()) { - requestMessage.setBody(ByteString.copyFrom(body.get())); - } - - if (headers != null) { - requestMessage.addAllHeaders(headers); - } - - SubProtocol.WebSocketMessage message - = SubProtocol.WebSocketMessage.newBuilder() - .setType(SubProtocol.WebSocketMessage.Type.REQUEST) - .setRequest(requestMessage) - .build(); - - return new ProtobufWebSocketMessage(message); - } - - @Override - public WebSocketMessage createResponse(long requestId, int status, String messageString, List headers, Optional body) { - SubProtocol.WebSocketResponseMessage.Builder responseMessage = - SubProtocol.WebSocketResponseMessage.newBuilder() - .setId(requestId) - .setStatus(status) - .setMessage(messageString); - - if (body.isPresent()) { - responseMessage.setBody(ByteString.copyFrom(body.get())); - } - - if (headers != null) { - responseMessage.addAllHeaders(headers); - } - - SubProtocol.WebSocketMessage message = - SubProtocol.WebSocketMessage.newBuilder() - .setType(SubProtocol.WebSocketMessage.Type.RESPONSE) - .setResponse(responseMessage) - .build(); - - return new ProtobufWebSocketMessage(message); - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketRequestMessage.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketRequestMessage.java deleted file mode 100644 index d1233f465..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketRequestMessage.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages.protobuf; - -import org.whispersystems.websocket.messages.WebSocketRequestMessage; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -public class ProtobufWebSocketRequestMessage implements WebSocketRequestMessage { - - private final SubProtocol.WebSocketRequestMessage message; - - ProtobufWebSocketRequestMessage(SubProtocol.WebSocketRequestMessage message) { - this.message = message; - } - - @Override - public String getVerb() { - return message.getVerb(); - } - - @Override - public String getPath() { - return message.getPath(); - } - - @Override - public Optional getBody() { - if (message.hasBody()) { - return Optional.of(message.getBody().toByteArray()); - } else { - return Optional.empty(); - } - } - - @Override - public long getRequestId() { - return message.getId(); - } - - @Override - public boolean hasRequestId() { - return message.hasId(); - } - - @Override - public Map getHeaders() { - Map results = new HashMap<>(); - - for (String header : message.getHeadersList()) { - String[] tokenized = header.split(":"); - - if (tokenized.length == 2 && tokenized[0] != null && tokenized[1] != null) { - results.put(tokenized[0].trim().toLowerCase(), tokenized[1].trim()); - } - } - - return results; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketResponseMessage.java b/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketResponseMessage.java deleted file mode 100644 index 62981772a..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketResponseMessage.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.messages.protobuf; - -import org.whispersystems.websocket.messages.WebSocketResponseMessage; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -public class ProtobufWebSocketResponseMessage implements WebSocketResponseMessage { - - private final SubProtocol.WebSocketResponseMessage message; - - public ProtobufWebSocketResponseMessage(SubProtocol.WebSocketResponseMessage message) { - this.message = message; - } - - @Override - public long getRequestId() { - return message.getId(); - } - - @Override - public int getStatus() { - return message.getStatus(); - } - - @Override - public String getMessage() { - return message.getMessage(); - } - - @Override - public Optional getBody() { - if (message.hasBody()) { - return Optional.of(message.getBody().toByteArray()); - } else { - return Optional.empty(); - } - } - - @Override - public Map getHeaders() { - Map results = new HashMap<>(); - - for (String header : message.getHeadersList()) { - String[] tokenized = header.split(":"); - - if (tokenized.length == 2 && tokenized[0] != null && tokenized[1] != null) { - results.put(tokenized[0].trim().toLowerCase(), tokenized[1].trim()); - } - } - - return results; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/session/ContextPrincipal.java b/websocket-resources/src/main/java/org/whispersystems/websocket/session/ContextPrincipal.java deleted file mode 100644 index 6ab381c30..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/session/ContextPrincipal.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.session; - -import java.security.Principal; - -public class ContextPrincipal implements Principal { - - private final WebSocketSessionContext context; - - public ContextPrincipal(WebSocketSessionContext context) { - this.context = context; - } - - @Override - public boolean equals(Object another) { - return another instanceof ContextPrincipal && - context.equals(((ContextPrincipal) another).context); - } - - @Override - public String toString() { - return super.toString(); - } - - @Override - public int hashCode() { - return context.hashCode(); - } - - @Override - public String getName() { - return "WebSocketSessionContext"; - } - - public WebSocketSessionContext getContext() { - return context; - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSession.java b/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSession.java deleted file mode 100644 index 5548d78e1..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSession.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.session; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Retention(RetentionPolicy.RUNTIME) -@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD }) -public @interface WebSocketSession { -} - diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContainerRequestValueFactory.java b/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContainerRequestValueFactory.java deleted file mode 100644 index b57ad4ed1..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContainerRequestValueFactory.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.session; - -import org.glassfish.jersey.server.ContainerRequest; -import org.whispersystems.websocket.WebSocketSecurityContext; - -import javax.ws.rs.core.SecurityContext; - -public class WebSocketSessionContainerRequestValueFactory { - private final ContainerRequest request; - - public WebSocketSessionContainerRequestValueFactory(ContainerRequest request) { - this.request = request; - } - - public WebSocketSessionContext provide() { - SecurityContext securityContext = request.getSecurityContext(); - - if (!(securityContext instanceof WebSocketSecurityContext)) { - throw new IllegalStateException("Security context isn't for websocket!"); - } - - WebSocketSessionContext sessionContext = ((WebSocketSecurityContext)securityContext).getSessionContext(); - - if (sessionContext == null) { - throw new IllegalStateException("No session context found for websocket!"); - } - - return sessionContext; - } - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContext.java b/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContext.java deleted file mode 100644 index 93205e8f9..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContext.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.session; - -import org.whispersystems.websocket.WebSocketClient; - -import java.util.LinkedList; -import java.util.List; - -public class WebSocketSessionContext { - - private final List closeListeners = new LinkedList<>(); - - private final WebSocketClient webSocketClient; - - private Object authenticated; - private boolean closed; - - public WebSocketSessionContext(WebSocketClient webSocketClient) { - this.webSocketClient = webSocketClient; - } - - public void setAuthenticated(Object authenticated) { - this.authenticated = authenticated; - } - - public T getAuthenticated(Class clazz) { - if (authenticated != null && clazz.equals(authenticated.getClass())) { - return clazz.cast(authenticated); - } - - throw new IllegalArgumentException("No authenticated type for: " + clazz + ", we have: " + authenticated); - } - - public Object getAuthenticated() { - return authenticated; - } - - public synchronized void addListener(WebSocketEventListener listener) { - if (!closed) this.closeListeners.add(listener); - else listener.onWebSocketClose(this, 1000, "Closed"); - } - - public WebSocketClient getClient() { - return webSocketClient; - } - - public synchronized void notifyClosed(int statusCode, String reason) { - for (WebSocketEventListener listener : closeListeners) { - listener.onWebSocketClose(this, statusCode, reason); - } - - closed = true; - } - - public interface WebSocketEventListener { - public void onWebSocketClose(WebSocketSessionContext context, int statusCode, String reason); - } - - -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContextValueFactoryProvider.java b/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContextValueFactoryProvider.java deleted file mode 100644 index 511434524..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContextValueFactoryProvider.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.session; - -import org.glassfish.jersey.internal.inject.AbstractBinder; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider; -import org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider; -import org.glassfish.jersey.server.model.Parameter; -import org.glassfish.jersey.server.spi.internal.ValueParamProvider; - -import javax.annotation.Nullable; -import javax.inject.Inject; -import javax.inject.Singleton; -import java.util.function.Function; - - -@Singleton -public class WebSocketSessionContextValueFactoryProvider extends AbstractValueParamProvider { - - @Inject - public WebSocketSessionContextValueFactoryProvider(MultivaluedParameterExtractorProvider mpep) { - super(() -> mpep, Parameter.Source.UNKNOWN); - } - - @Nullable - @Override - protected Function createValueProvider(Parameter parameter) { - if (!parameter.isAnnotationPresent(WebSocketSession.class)) { - return null; - } else if (WebSocketSessionContext.class.equals(parameter.getRawType())) { - return request -> new WebSocketSessionContainerRequestValueFactory(request).provide(); - } else { - throw new IllegalArgumentException("Can't inject custom type"); - } - } - - public static class Binder extends AbstractBinder { - - public Binder() { } - - @Override - protected void configure() { - bind(WebSocketSessionContextValueFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class); - } - } -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketConnectListener.java b/websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketConnectListener.java deleted file mode 100644 index be6f043de..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketConnectListener.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.setup; - -import org.whispersystems.websocket.session.WebSocketSessionContext; - -public interface WebSocketConnectListener { - public void onWebSocketConnect(WebSocketSessionContext context); -} diff --git a/websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketEnvironment.java b/websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketEnvironment.java deleted file mode 100644 index 64413b383..000000000 --- a/websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketEnvironment.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.setup; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.dropwizard.jersey.DropwizardResourceConfig; -import io.dropwizard.setup.Environment; -import org.glassfish.jersey.server.ResourceConfig; -import org.whispersystems.websocket.auth.WebSocketAuthenticator; -import org.whispersystems.websocket.configuration.WebSocketConfiguration; -import org.whispersystems.websocket.logging.WebsocketRequestLog; -import org.whispersystems.websocket.messages.WebSocketMessageFactory; -import org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory; - -import javax.validation.Validator; -import java.security.Principal; - -public class WebSocketEnvironment { - - private final ResourceConfig jerseyConfig; - private final ObjectMapper objectMapper; - private final Validator validator; - private final WebsocketRequestLog requestLog; - private final long idleTimeoutMillis; - - private WebSocketAuthenticator authenticator; - private WebSocketMessageFactory messageFactory; - private WebSocketConnectListener connectListener; - - public WebSocketEnvironment(Environment environment, WebSocketConfiguration configuration) { - this(environment, configuration, 60000); - } - - public WebSocketEnvironment(Environment environment, WebSocketConfiguration configuration, long idleTimeoutMillis) { - this(environment, configuration.getRequestLog().build("websocket"), idleTimeoutMillis); - } - - public WebSocketEnvironment(Environment environment, WebsocketRequestLog requestLog, long idleTimeoutMillis) { - this.jerseyConfig = new DropwizardResourceConfig(environment.metrics()); - this.objectMapper = environment.getObjectMapper(); - this.validator = environment.getValidator(); - this.requestLog = requestLog; - this.messageFactory = new ProtobufWebSocketMessageFactory(); - this.idleTimeoutMillis = idleTimeoutMillis; - } - - public ResourceConfig jersey() { - return jerseyConfig; - } - - public WebSocketAuthenticator getAuthenticator() { - return authenticator; - } - - public void setAuthenticator(WebSocketAuthenticator authenticator) { - this.authenticator = authenticator; - } - - public long getIdleTimeoutMillis() { - return idleTimeoutMillis; - } - - public ObjectMapper getObjectMapper() { - return objectMapper; - } - - public WebsocketRequestLog getRequestLog() { - return requestLog; - } - - public Validator getValidator() { - return validator; - } - - public WebSocketMessageFactory getMessageFactory() { - return messageFactory; - } - - public void setMessageFactory(WebSocketMessageFactory messageFactory) { - this.messageFactory = messageFactory; - } - - public WebSocketConnectListener getConnectListener() { - return connectListener; - } - - public void setConnectListener(WebSocketConnectListener connectListener) { - this.connectListener = connectListener; - } -} diff --git a/websocket-resources/src/main/proto/WebSocketProtocol.proto b/websocket-resources/src/main/proto/WebSocketProtocol.proto deleted file mode 100644 index cc28f8925..000000000 --- a/websocket-resources/src/main/proto/WebSocketProtocol.proto +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -syntax = "proto2"; - -package signalservice; - -option java_package = "org.whispersystems.websocket.messages.protobuf"; -option java_outer_classname = "SubProtocol"; - -message WebSocketRequestMessage { - optional string verb = 1; - optional string path = 2; - optional bytes body = 3; - repeated string headers = 5; - optional uint64 id = 4; -} - -message WebSocketResponseMessage { - optional uint64 id = 1; - optional uint32 status = 2; - optional string message = 3; - repeated string headers = 5; - optional bytes body = 4; -} - -message WebSocketMessage { - enum Type { - UNKNOWN = 0; - REQUEST = 1; - RESPONSE = 2; - } - - optional Type type = 1; - optional WebSocketRequestMessage request = 2; - optional WebSocketResponseMessage response = 3; -} diff --git a/websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderFactoryTest.java b/websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderFactoryTest.java deleted file mode 100644 index 33baf6e29..000000000 --- a/websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderFactoryTest.java +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2013-2021 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import io.dropwizard.jersey.DropwizardResourceConfig; -import java.io.IOException; -import java.security.Principal; -import java.util.Optional; -import javax.security.auth.Subject; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.UpgradeRequest; -import org.eclipse.jetty.websocket.api.WebSocketPolicy; -import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; -import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; -import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; -import org.glassfish.jersey.server.ResourceConfig; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.websocket.auth.AuthenticationException; -import org.whispersystems.websocket.auth.WebSocketAuthenticator; -import org.whispersystems.websocket.configuration.WebSocketConfiguration; -import org.whispersystems.websocket.setup.WebSocketEnvironment; - -public class WebSocketResourceProviderFactoryTest { - - private ResourceConfig jerseyEnvironment; - private WebSocketEnvironment environment; - private WebSocketAuthenticator authenticator; - private ServletUpgradeRequest request; - private ServletUpgradeResponse response; - - @BeforeEach - void setup() { - jerseyEnvironment = new DropwizardResourceConfig(); - //noinspection unchecked - environment = mock(WebSocketEnvironment.class); - //noinspection unchecked - authenticator = mock(WebSocketAuthenticator.class); - request = mock(ServletUpgradeRequest.class); - response = mock(ServletUpgradeResponse.class); - - } - - @Test - void testUnauthorized() throws AuthenticationException, IOException { - when(environment.getAuthenticator()).thenReturn(authenticator); - when(authenticator.authenticate(eq(request))).thenReturn( - new WebSocketAuthenticator.AuthenticationResult<>(Optional.empty(), true)); - when(environment.jersey()).thenReturn(jerseyEnvironment); - - WebSocketResourceProviderFactory factory = new WebSocketResourceProviderFactory<>(environment, Account.class, - mock(WebSocketConfiguration.class)); - Object connection = factory.createWebSocket(request, response); - - assertNull(connection); - verify(response).sendForbidden(eq("Unauthorized")); - verify(authenticator).authenticate(eq(request)); - } - - @Test - void testValidAuthorization() throws AuthenticationException { - Session session = mock(Session.class); - Account account = new Account(); - - when(environment.getAuthenticator()).thenReturn(authenticator); - when(authenticator.authenticate(eq(request))).thenReturn( - new WebSocketAuthenticator.AuthenticationResult<>(Optional.of(account), true)); - when(environment.jersey()).thenReturn(jerseyEnvironment); - when(session.getUpgradeRequest()).thenReturn(mock(UpgradeRequest.class)); - - WebSocketResourceProviderFactory factory = new WebSocketResourceProviderFactory<>(environment, Account.class, - mock(WebSocketConfiguration.class)); - Object connection = factory.createWebSocket(request, response); - - assertNotNull(connection); - verifyNoMoreInteractions(response); - verify(authenticator).authenticate(eq(request)); - - ((WebSocketResourceProvider) connection).onWebSocketConnect(session); - - assertNotNull(((WebSocketResourceProvider) connection).getContext().getAuthenticated()); - assertEquals(((WebSocketResourceProvider) connection).getContext().getAuthenticated(), account); - } - - @Test - void testErrorAuthorization() throws AuthenticationException, IOException { - when(environment.getAuthenticator()).thenReturn(authenticator); - when(authenticator.authenticate(eq(request))).thenThrow(new AuthenticationException("database failure")); - when(environment.jersey()).thenReturn(jerseyEnvironment); - - WebSocketResourceProviderFactory factory = new WebSocketResourceProviderFactory<>(environment, - Account.class, - mock(WebSocketConfiguration.class)); - Object connection = factory.createWebSocket(request, response); - - assertNull(connection); - verify(response).sendError(eq(500), eq("Failure")); - verify(authenticator).authenticate(eq(request)); - } - - @Test - void testConfigure() { - WebSocketServletFactory servletFactory = mock(WebSocketServletFactory.class); - when(environment.jersey()).thenReturn(jerseyEnvironment); - when(servletFactory.getPolicy()).thenReturn(mock(WebSocketPolicy.class)); - - WebSocketResourceProviderFactory factory = new WebSocketResourceProviderFactory<>(environment, - Account.class, - mock(WebSocketConfiguration.class)); - factory.configure(servletFactory); - - verify(servletFactory).setCreator(eq(factory)); - } - - - private static class Account implements Principal { - @Override - public String getName() { - return null; - } - - @Override - public boolean implies(Subject subject) { - return false; - } - } - - -} diff --git a/websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderTest.java b/websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderTest.java deleted file mode 100644 index 80557ae17..000000000 --- a/websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderTest.java +++ /dev/null @@ -1,809 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.anyInt; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.net.HttpHeaders; -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; -import io.dropwizard.auth.Auth; -import io.dropwizard.jersey.DropwizardResourceConfig; -import io.dropwizard.jersey.jackson.JacksonMessageBodyProvider; -import java.io.OutputStream; -import java.nio.ByteBuffer; -import java.security.Principal; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import javax.validation.Valid; -import javax.validation.constraints.Min; -import javax.validation.constraints.NotEmpty; -import javax.ws.rs.Consumes; -import javax.ws.rs.GET; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedHashMap; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; -import javax.ws.rs.ext.Provider; -import org.eclipse.jetty.websocket.api.CloseStatus; -import org.eclipse.jetty.websocket.api.RemoteEndpoint; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.UpgradeRequest; -import org.eclipse.jetty.websocket.api.WriteCallback; -import org.glassfish.jersey.server.ApplicationHandler; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.ContainerResponse; -import org.glassfish.jersey.server.ResourceConfig; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.stubbing.Answer; -import org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider; -import org.whispersystems.websocket.logging.WebsocketRequestLog; -import org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory; -import org.whispersystems.websocket.messages.protobuf.SubProtocol; -import org.whispersystems.websocket.session.WebSocketSession; -import org.whispersystems.websocket.session.WebSocketSessionContext; -import org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider; -import org.whispersystems.websocket.setup.WebSocketConnectListener; - -class WebSocketResourceProviderTest { - - @Test - void testOnConnect() { - ApplicationHandler applicationHandler = mock(ApplicationHandler.class); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketConnectListener connectListener = mock(WebSocketConnectListener.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", - applicationHandler, requestLog, - new TestPrincipal("fooz"), - new ProtobufWebSocketMessageFactory(), - Optional.of(connectListener), - 30000); - - Session session = mock(Session.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - - provider.onWebSocketConnect(session); - - verify(session, never()).close(anyInt(), anyString()); - verify(session, never()).close(); - verify(session, never()).close(any(CloseStatus.class)); - - ArgumentCaptor contextArgumentCaptor = ArgumentCaptor.forClass( - WebSocketSessionContext.class); - verify(connectListener).onWebSocketConnect(contextArgumentCaptor.capture()); - - assertThat(contextArgumentCaptor.getValue().getAuthenticated(TestPrincipal.class).getName()).isEqualTo("fooz"); - } - - @Test - void testMockedRouteMessageSuccess() throws Exception { - ApplicationHandler applicationHandler = mock(ApplicationHandler.class); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("foo"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - ContainerResponse response = mock(ContainerResponse.class); - when(response.getStatus()).thenReturn(200); - when(response.getStatusInfo()).thenReturn(new Response.StatusType() { - @Override - public int getStatusCode() { - return 200; - } - - @Override - public Response.Status.Family getFamily() { - return Response.Status.Family.SUCCESSFUL; - } - - @Override - public String getReasonPhrase() { - return "OK"; - } - }); - - ArgumentCaptor responseOutputStream = ArgumentCaptor.forClass(OutputStream.class); - - when(applicationHandler.apply(any(ContainerRequest.class), responseOutputStream.capture())) - .thenAnswer((Answer>) invocation -> { - responseOutputStream.getValue().write("hello world!".getBytes()); - return CompletableFuture.completedFuture(response); - }); - - provider.onWebSocketConnect(session); - - verify(session, never()).close(anyInt(), anyString()); - verify(session, never()).close(); - verify(session, never()).close(any(CloseStatus.class)); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/bar", - new LinkedList<>(), Optional.of("hello world!".getBytes())).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ContainerRequest.class); - ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(applicationHandler).apply(requestCaptor.capture(), any(OutputStream.class)); - - ContainerRequest bundledRequest = requestCaptor.getValue(); - - assertThat(bundledRequest.getRequest().getMethod()).isEqualTo("GET"); - assertThat(bundledRequest.getBaseUri().toString()).isEqualTo("/"); - assertThat(bundledRequest.getPath(false)).isEqualTo("bar"); - - verify(requestLog).log(eq("127.0.0.1"), eq(bundledRequest), eq(response)); - verify(remoteEndpoint).sendBytesByFuture(responseCaptor.capture()); - - SubProtocol.WebSocketMessage responseMessageContainer = SubProtocol.WebSocketMessage.parseFrom( - responseCaptor.getValue().array()); - assertThat(responseMessageContainer.getResponse().getId()).isEqualTo(111L); - assertThat(responseMessageContainer.getResponse().getStatus()).isEqualTo(200); - assertThat(responseMessageContainer.getResponse().getMessage()).isEqualTo("OK"); - assertThat(responseMessageContainer.getResponse().getBody()).isEqualTo( - ByteString.copyFrom("hello world!".getBytes())); - } - - @Test - void testMockedRouteMessageFailure() throws Exception { - ApplicationHandler applicationHandler = mock(ApplicationHandler.class); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("foo"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - when(applicationHandler.apply(any(ContainerRequest.class), any(OutputStream.class))).thenReturn( - CompletableFuture.failedFuture(new IllegalStateException("foo"))); - - provider.onWebSocketConnect(session); - - verify(session, never()).close(anyInt(), anyString()); - verify(session, never()).close(); - verify(session, never()).close(any(CloseStatus.class)); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/bar", - new LinkedList<>(), Optional.of("hello world!".getBytes())).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ContainerRequest.class); - - verify(applicationHandler).apply(requestCaptor.capture(), any(OutputStream.class)); - - ContainerRequest bundledRequest = requestCaptor.getValue(); - - assertThat(bundledRequest.getRequest().getMethod()).isEqualTo("GET"); - assertThat(bundledRequest.getBaseUri().toString()).isEqualTo("/"); - assertThat(bundledRequest.getPath(false)).isEqualTo("bar"); - - ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseCaptor.capture()); - - SubProtocol.WebSocketMessage responseMessageContainer = SubProtocol.WebSocketMessage.parseFrom( - responseCaptor.getValue().array()); - assertThat(responseMessageContainer.getResponse().getStatus()).isEqualTo(500); - assertThat(responseMessageContainer.getResponse().getMessage()).isEqualTo("Error response"); - assertThat(responseMessageContainer.getResponse().hasBody()).isFalse(); - } - - @Test - void testActualRouteMessageSuccess() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("foo"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/hello", - new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getMessage()).isEqualTo("OK"); - assertThat(response.getBody()).isEqualTo(ByteString.copyFrom("Hello!".getBytes())); - } - - @Test - void testActualRouteMessageNotFound() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("foo"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", - "/v1/test/doesntexist", new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(404); - assertThat(response.getMessage()).isEqualTo("Not Found"); - assertThat(response.hasBody()).isFalse(); - } - - @Test - void testActualRouteMessageAuthorized() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("authorizedUserName"), new ProtobufWebSocketMessageFactory(), Optional.empty(), - 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/world", - new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getMessage()).isEqualTo("OK"); - assertThat(response.getBody().toStringUtf8()).isEqualTo("World: authorizedUserName"); - } - - @Test - void testActualRouteMessageUnauthorized() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, null, new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/world", - new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(401); - assertThat(response.hasBody()).isFalse(); - } - - @Test - void testActualRouteMessageOptionalAuthorizedPresent() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("something"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/optional", - new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getMessage()).isEqualTo("OK"); - assertThat(response.getBody().toStringUtf8()).isEqualTo("World: something"); - } - - @Test - void testActualRouteMessageOptionalAuthorizedEmpty() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, null, new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/optional", - new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getMessage()).isEqualTo("OK"); - assertThat(response.getBody().toStringUtf8()).isEqualTo("Empty world"); - } - - @Test - void testActualRouteMessagePutAuthenticatedEntity() throws InvalidProtocolBufferException, JsonProcessingException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("gooduser"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "PUT", - "/v1/test/some/testparam", List.of("Content-Type: application/json"), - Optional.of(new ObjectMapper().writeValueAsBytes(new TestResource.TestEntity("mykey", 1001)))).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getMessage()).isEqualTo("OK"); - assertThat(response.getBody().toStringUtf8()).isEqualTo("gooduser:testparam:mykey:1001"); - } - - @Test - void testActualRouteMessagePutAuthenticatedBadEntity() - throws InvalidProtocolBufferException, JsonProcessingException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("gooduser"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "PUT", - "/v1/test/some/testparam", List.of("Content-Type: application/json"), - Optional.of(new ObjectMapper().writeValueAsBytes(new TestResource.TestEntity("mykey", 5)))).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(400); - assertThat(response.getMessage()).isEqualTo("Bad Request"); - assertThat(response.hasBody()).isFalse(); - } - - @Test - void testActualRouteMessageExceptionMapping() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new TestExceptionMapper()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("gooduser"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", - "/v1/test/exception/map", List.of("Content-Type: application/json"), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(1337); - assertThat(response.hasBody()).isFalse(); - } - - @Test - void testActualRouteSessionContextInjection() throws InvalidProtocolBufferException { - ResourceConfig resourceConfig = new DropwizardResourceConfig(); - resourceConfig.register(new TestResource()); - resourceConfig.register(new TestExceptionMapper()); - resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder()); - resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class)); - resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper())); - - ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig); - WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class); - WebSocketResourceProvider provider = new WebSocketResourceProvider<>("127.0.0.1", applicationHandler, - requestLog, new TestPrincipal("gooduser"), new ProtobufWebSocketMessageFactory(), Optional.empty(), 30000); - - Session session = mock(Session.class); - RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class); - UpgradeRequest request = mock(UpgradeRequest.class); - - when(session.getUpgradeRequest()).thenReturn(request); - when(session.getRemote()).thenReturn(remoteEndpoint); - - provider.onWebSocketConnect(session); - - byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), "GET", "/v1/test/keepalive", - new LinkedList<>(), Optional.empty()).toByteArray(); - - provider.onWebSocketBinary(message, 0, message.length); - - ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytes(requestCaptor.capture(), any(WriteCallback.class)); - - SubProtocol.WebSocketRequestMessage requestMessage = getRequest(requestCaptor); - assertThat(requestMessage.getVerb()).isEqualTo("GET"); - assertThat(requestMessage.getPath()).isEqualTo("/v1/miccheck"); - assertThat(requestMessage.getBody().toStringUtf8()).isEqualTo("smert ze smert"); - - byte[] clientResponse = new ProtobufWebSocketMessageFactory().createResponse(requestMessage.getId(), 200, "OK", - new LinkedList<>(), Optional.of("my response".getBytes())).toByteArray(); - - provider.onWebSocketBinary(clientResponse, 0, clientResponse.length); - - ArgumentCaptor responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class); - - verify(remoteEndpoint).sendBytesByFuture(responseBytesCaptor.capture()); - - SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor); - - assertThat(response.getId()).isEqualTo(111L); - assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getMessage()).isEqualTo("OK"); - assertThat(response.getBody().toStringUtf8()).isEqualTo("my response"); - } - - @Test - void testGetHeaderList() { - assertThat(WebSocketResourceProvider.getHeaderList(new MultivaluedHashMap<>())).isEmpty(); - - { - final MultivaluedMap headers = new MultivaluedHashMap<>(); - headers.put("test", Arrays.asList("a", "b", "c")); - - final List headerStrings = WebSocketResourceProvider.getHeaderList(headers); - - assertThat(headerStrings).hasSize(1); - assertThat(headerStrings).contains("test:a"); - } - } - - @Test - void testShouldIncludeUpgradeRequestHeader() { - assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("Upgrade")).isFalse(); - assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("Connection")).isFalse(); - assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("Sec-WebSocket-Key")).isFalse(); - assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader(HttpHeaders.USER_AGENT)).isTrue(); - assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("X-Forwarded-For")).isTrue(); - assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader("X-Signal-Receive-Stories")).isTrue(); - } - - @Test - void testShouldIncludeRequestMessageHeader() { - assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("X-Forwarded-For")).isFalse(); - assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader(HttpHeaders.USER_AGENT)).isTrue(); - assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader("X-Signal-Receive-Stories")).isTrue(); - } - - @Test - void testGetCombinedHeaders() { - final Map> upgradeRequestHeaders = Map.of( - "Host", List.of("server.example.com"), - "Upgrade", List.of("websocket"), - "Connection", List.of("Upgrade"), - "Sec-WebSocket-Key", List.of("dGhlIHNhbXBsZSBub25jZQ=="), - "Sec-WebSocket-Protocol", List.of("chat, superchat"), - "Sec-WebSocket-Version", List.of("13"), - "X-Forwarded-For", List.of("127.0.0.1"), - HttpHeaders.USER_AGENT, List.of("Upgrade request user agent")); - - final Map requestMessageHeaders = Map.of( - "X-Forwarded-For", "192.168.0.1", - HttpHeaders.USER_AGENT, "Request message user agent"); - - final Map> expectedHeaders = Map.of( - "Host", List.of("server.example.com"), - "X-Forwarded-For", List.of("127.0.0.1"), - HttpHeaders.USER_AGENT, List.of("Request message user agent")); - - assertThat(WebSocketResourceProvider.getCombinedHeaders(upgradeRequestHeaders, requestMessageHeaders)).isEqualTo( - expectedHeaders); - } - - private SubProtocol.WebSocketResponseMessage getResponse(ArgumentCaptor responseCaptor) - throws InvalidProtocolBufferException { - return SubProtocol.WebSocketMessage.parseFrom(responseCaptor.getValue().array()).getResponse(); - } - - private SubProtocol.WebSocketRequestMessage getRequest(ArgumentCaptor requestCaptor) - throws InvalidProtocolBufferException { - return SubProtocol.WebSocketMessage.parseFrom(requestCaptor.getValue().array()).getRequest(); - } - - - public static class TestPrincipal implements Principal { - - private final String name; - - private TestPrincipal(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - } - - public static class TestException extends Exception { - - public TestException(String message) { - super(message); - } - } - - @Provider - public static class TestExceptionMapper implements ExceptionMapper { - - @Override - public Response toResponse(TestException exception) { - return Response.status(1337).build(); - } - } - - @Path("/v1/test") - public static class TestResource { - - @GET - @Path("/hello") - public String testGetHello() { - return "Hello!"; - } - - @GET - @Path("/world") - public String testAuthorizedHello(@Auth TestPrincipal user) { - if (user == null) { - throw new AssertionError(); - } - - return "World: " + user.getName(); - } - - @GET - @Path("/optional") - public String testAuthorizedHello(@Auth Optional user) { - if (user.isPresent()) { - return "World: " + user.get().getName(); - } else { - return "Empty world"; - } - } - - @PUT - @Path("/some/{param}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - public Response testSet(@Auth TestPrincipal user, @PathParam("param") String param, @Valid TestEntity entity) { - return Response.ok(user.name + ":" + param + ":" + entity.key + ":" + entity.value).build(); - } - - @GET - @Path("/exception/map") - public Response testExceptionMapping() throws TestException { - throw new TestException("I'd like to map this"); - } - - @GET - @Path("/keepalive") - public CompletableFuture testContextInjection(@WebSocketSession WebSocketSessionContext context) { - if (context == null) { - throw new AssertionError(); - } - - return context.getClient() - .sendRequest("GET", "/v1/miccheck", new LinkedList<>(), Optional.of("smert ze smert".getBytes())) - .thenApply(response -> Response.ok().entity(new String(response.getBody().get())).build()); - } - - public static class TestEntity { - - public TestEntity(String key, long value) { - this.key = key; - this.value = value; - } - - public TestEntity() { - } - - @JsonProperty - @NotEmpty - private String key; - - @JsonProperty - @Min(100) - private long value; - - } - } - -} diff --git a/websocket-resources/src/test/java/org/whispersystems/websocket/logging/WebSocketRequestLogTest.java b/websocket-resources/src/test/java/org/whispersystems/websocket/logging/WebSocketRequestLogTest.java deleted file mode 100644 index dd4b8fea0..000000000 --- a/websocket-resources/src/test/java/org/whispersystems/websocket/logging/WebSocketRequestLogTest.java +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2013-2020 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ -package org.whispersystems.websocket.logging; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.core.OutputStreamAppender; -import ch.qos.logback.core.spi.DeferredProcessingAware; -import com.google.common.net.HttpHeaders; -import io.dropwizard.logging.AbstractOutputStreamAppenderFactory; -import java.io.ByteArrayOutputStream; -import java.net.URI; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import javax.ws.rs.core.Response; -import org.glassfish.jersey.internal.MapPropertiesDelegate; -import org.glassfish.jersey.server.ContainerRequest; -import org.glassfish.jersey.server.ContainerResponse; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.whispersystems.websocket.WebSocketSecurityContext; -import org.whispersystems.websocket.session.ContextPrincipal; -import org.whispersystems.websocket.session.WebSocketSessionContext; - -public class WebSocketRequestLogTest { - - private final static Locale ORIGINAL_DEFAULT_LOCALE = Locale.getDefault(); - - @BeforeEach - void beforeEachTest() { - Locale.setDefault(Locale.ENGLISH); - } - - @AfterEach - void afterEachTest() { - Locale.setDefault(ORIGINAL_DEFAULT_LOCALE); - } - - @Test - void testLogLineWithoutHeaders() throws InterruptedException { - WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class); - - ListAppender listAppender = new ListAppender<>(); - WebsocketRequestLoggerFactory requestLoggerFactory = new WebsocketRequestLoggerFactory(); - requestLoggerFactory.appenders = List.of(new ListAppenderFactory<>(listAppender)); - - WebsocketRequestLog requestLog = requestLoggerFactory.build("test-logger"); - ContainerRequest request = new ContainerRequest(null, URI.create("/v1/test"), "GET", - new WebSocketSecurityContext(new ContextPrincipal(sessionContext)), new MapPropertiesDelegate(new HashMap<>()), - null); - ContainerResponse response = new ContainerResponse(request, Response.ok("My response body").build()); - - requestLog.log("123.456.789.123", request, response); - - listAppender.waitForListSize(1); - assertThat(listAppender.list.size()).isEqualTo(1); - - String loggedLine = new String(listAppender.outputStream.toByteArray()); - assertThat(loggedLine).matches( - "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"); - } - - @Test - void testLogLineWithHeaders() throws InterruptedException { - WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class); - - ListAppender listAppender = new ListAppender<>(); - WebsocketRequestLoggerFactory requestLoggerFactory = new WebsocketRequestLoggerFactory(); - requestLoggerFactory.appenders = List.of(new ListAppenderFactory<>(listAppender)); - - WebsocketRequestLog requestLog = requestLoggerFactory.build("test-logger"); - ContainerRequest request = new ContainerRequest(null, URI.create("/v1/test"), "GET", - new WebSocketSecurityContext(new ContextPrincipal(sessionContext)), new MapPropertiesDelegate(new HashMap<>()), - null); - request.header(HttpHeaders.USER_AGENT, "SmertZeSmert"); - request.header("Referer", "https://moxie.org"); - ContainerResponse response = new ContainerResponse(request, Response.ok("My response body").build()); - - requestLog.log("123.456.789.123", request, response); - - listAppender.waitForListSize(1); - assertThat(listAppender.list.size()).isEqualTo(1); - - String loggedLine = new String(listAppender.outputStream.toByteArray()); - assertThat(loggedLine).matches( - "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"); - - System.out.println(listAppender.list.get(0)); - System.out.println(new String(listAppender.outputStream.toByteArray())); - } - - private static class ListAppenderFactory extends - AbstractOutputStreamAppenderFactory { - - private final ListAppender listAppender; - - public ListAppenderFactory(ListAppender listAppender) { - this.listAppender = listAppender; - } - - @Override - protected OutputStreamAppender appender(LoggerContext context) { - listAppender.setContext(context); - return listAppender; - } - } - - private static class ListAppender extends OutputStreamAppender { - - public final List list = new ArrayList(); - public final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - - protected void append(E e) { - super.append(e); - - synchronized (list) { - list.add(e); - list.notifyAll(); - } - } - - @Override - public void start() { - setOutputStream(outputStream); - super.start(); - } - - public void waitForListSize(int size) throws InterruptedException { - synchronized (list) { - while (list.size() < size) { - list.wait(5000); - } - } - } - - } - - -}