Files
fluxer/fluxer_gateway/test/gateway_handler_tests.erl
T

546 lines
21 KiB
Erlang

%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(gateway_handler_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(TEST_GATEWAY_RATE_LIMIT_MAX_EVENTS, 600).
-define(TEST_IDENTIFY_MAX_PER_IP, 300).
websocket_info_session_reconnect_sends_reconnect_then_close_test() ->
assert_reconnect_close(
gateway_handler:websocket_info(session_reconnect, new_json_state()),
<<"Session drain requested; reconnect to continue">>
).
start_session_with_drain_guard_holds_during_drain_test() ->
Request = #{},
assert_pending_identify(
gateway_handler_identify:start_session_with_drain_guard(
true, Request, self(), new_json_state()
),
Request,
self()
).
handle_session_start_result_holds_draining_without_context_test() ->
assert_held_without_pending(
gateway_handler_identify:handle_session_start_result(
{error, draining}, new_json_state()
)
).
handle_session_start_result_holds_not_eligible_without_context_test() ->
assert_held_without_pending(
gateway_handler_identify:handle_session_start_result(
{error, not_eligible}, new_json_state()
)
).
start_session_zero_rollout_holds_socket_test() ->
OldConfig = gateway_rollout_config:get(),
try
persistent_term:put(gateway_rollout_config, OldConfig#{
<<"session_rollout_percentage">> => 0
}),
Request = #{},
assert_pending_identify(
gateway_handler_identify:start_session(Request, self(), new_json_state()),
Request,
self()
)
after
persistent_term:put(gateway_rollout_config, OldConfig)
end.
handle_session_start_result_retries_exhausted_holds_without_context_test() ->
State = (gateway_handler:new_state())#{
version => 1, encoding => json, compress_ctx => gateway_compress:new_context(none)
},
assert_held_without_pending(
gateway_handler_identify:handle_session_start_result(
{error, {retries_exhausted, timeout}}, State
)
).
handle_session_start_result_retries_exhausted_5xx_holds_without_context_test() ->
State = (gateway_handler:new_state())#{
version => 1, encoding => json, compress_ctx => gateway_compress:new_context(none)
},
assert_held_without_pending(
gateway_handler_identify:handle_session_start_result(
{error, {retries_exhausted, {rpc_error, 503, <<"Service unavailable">>}}}, State
)
).
handle_session_start_result_server_error_closes_with_unknown_error_test() ->
State = (gateway_handler:new_state())#{
version => 1, encoding => json, compress_ctx => gateway_compress:new_context(none)
},
{[{close, CloseCode, _Reason}], _NewState} =
gateway_handler_identify:handle_session_start_result(
{error, {server_error, 500}}, State
),
?assertEqual(constants:close_code_to_num(unknown_error), CloseCode).
websocket_init_schedules_tokened_heartbeat_timer_test() ->
{[{text, _Frame}], State} = gateway_handler:websocket_init(new_json_state()),
?assertMatch({_TimerRef, _Token}, maps:get(heartbeat_timer, State)),
gateway_handler_heartbeat:cancel_heartbeat_timer(State).
heartbeat_check_ignores_stale_token_test() ->
State0 = heartbeat_test_state(),
State1 = gateway_handler_heartbeat:schedule_heartbeat_check(State0),
{TimerRef, Token} = maps:get(heartbeat_timer, State1),
{ok, State2} = gateway_handler:websocket_info({heartbeat_check, make_ref()}, State1),
?assertEqual({TimerRef, Token}, maps:get(heartbeat_timer, State2)),
gateway_handler_heartbeat:cancel_heartbeat_timer(State2).
heartbeat_check_replaces_matched_timer_test() ->
State0 = heartbeat_test_state(),
State1 = gateway_handler_heartbeat:schedule_heartbeat_check(State0),
{TimerRef, Token} = maps:get(heartbeat_timer, State1),
{ok, State2} = gateway_handler:websocket_info({heartbeat_check, Token}, State1),
?assertMatch({_NewTimerRef, _NewToken}, maps:get(heartbeat_timer, State2)),
?assertNotEqual({TimerRef, Token}, maps:get(heartbeat_timer, State2)),
gateway_handler_heartbeat:cancel_heartbeat_timer(State2).
legacy_heartbeat_check_ignored_when_tokened_timer_active_test() ->
State0 = heartbeat_test_state(),
State1 = gateway_handler_heartbeat:schedule_heartbeat_check(State0),
{TimerRef, Token} = maps:get(heartbeat_timer, State1),
{ok, State2} = gateway_handler:websocket_info({heartbeat_check}, State1),
?assertEqual({TimerRef, Token}, maps:get(heartbeat_timer, State2)),
gateway_handler_heartbeat:cancel_heartbeat_timer(State2).
pending_identify_retry_ignores_stale_token_test() ->
Timer = erlang:send_after(60000, self(), stale_pending_identify_retry),
CurrentToken = make_ref(),
State0 = (new_json_state())#{
pending_identify => {#{token => <<"t">>}, self()},
pending_identify_retry_timer => {Timer, CurrentToken}
},
{ok, State1} = gateway_handler:websocket_info(
{retry_pending_identify, make_ref()}, State0
),
?assertEqual(State0, State1),
cancel_pending_identify_timer(State1).
legacy_pending_identify_retry_ignored_when_tokened_timer_active_test() ->
Timer = erlang:send_after(60000, self(), stale_pending_identify_retry),
CurrentToken = make_ref(),
State0 = (new_json_state())#{
pending_identify => {#{token => <<"t">>}, self()},
pending_identify_retry_timer => {Timer, CurrentToken}
},
{ok, State1} = gateway_handler:websocket_info(retry_pending_identify, State0),
?assertEqual(State0, State1),
cancel_pending_identify_timer(State1).
parse_forwarded_for_ipv4_test() ->
?assertEqual(<<"203.0.113.7">>, gateway_handler:parse_forwarded_for(<<"203.0.113.7">>)).
parse_forwarded_for_ipv4_with_port_test() ->
?assertEqual(
<<"203.0.113.7">>, gateway_handler:parse_forwarded_for(<<"203.0.113.7:8080">>)
).
parse_forwarded_for_ipv4_with_port_and_extra_entries_test() ->
?assertEqual(
<<"203.0.113.7">>,
gateway_handler:parse_forwarded_for(<<" 203.0.113.7:8080 , 10.0.0.1">>)
).
parse_forwarded_for_ipv6_test() ->
?assertEqual(<<"2001:db8::1">>, gateway_handler:parse_forwarded_for(<<"2001:db8::1">>)).
parse_forwarded_for_ipv6_with_brackets_test() ->
?assertEqual(<<"2001:db8::1">>, gateway_handler:parse_forwarded_for(<<"[2001:db8::1]">>)).
parse_forwarded_for_ipv6_with_brackets_and_port_test() ->
?assertEqual(
<<"2001:db8::1">>, gateway_handler:parse_forwarded_for(<<"[2001:db8::1]:443">>)
).
parse_forwarded_for_ipv6_with_spaces_test() ->
?assertEqual(
<<"2001:db8::1">>, gateway_handler:parse_forwarded_for(<<" [2001:db8::1] ">>)
).
parse_forwarded_for_invalid_ip_test() ->
?assertEqual(<<>>, gateway_handler:parse_forwarded_for(<<"not_an_ip">>)).
parse_forwarded_for_invalid_ipv4_octet_test() ->
?assertEqual(<<>>, gateway_handler:parse_forwarded_for(<<"203.0.113.300">>)).
parse_forwarded_for_unterminated_bracket_test() ->
?assertEqual(<<>>, gateway_handler:parse_forwarded_for(<<"[2001:db8::1">>)).
parse_version_test() ->
?assertEqual(1, gateway_handler:parse_version(<<"1">>)),
?assertEqual(undefined, gateway_handler:parse_version(<<"2">>)),
?assertEqual(undefined, gateway_handler:parse_version(undefined)).
parse_ignored_events_test() ->
?assertEqual({ok, []}, gateway_handler_identify:parse_ignored_events(undefined)),
?assertEqual({ok, []}, gateway_handler_identify:parse_ignored_events(null)),
?assertEqual(
{ok, [<<"TYPING_START">>]},
gateway_handler_identify:parse_ignored_events([<<"typing_start">>])
),
?assertEqual(
{error, invalid_ignored_events}, gateway_handler_identify:parse_ignored_events([123])
),
?assertEqual(
{error, invalid_ignored_events},
gateway_handler_identify:parse_ignored_events(<<"not_a_list">>)
).
adjust_status_test() ->
?assertEqual(invisible, gateway_handler_dispatch:adjust_status(offline)),
?assertEqual(online, gateway_handler_dispatch:adjust_status(online)),
?assertEqual(idle, gateway_handler_dispatch:adjust_status(idle)).
check_rate_limit_blocks_general_flood_test() ->
with_rate_limits_enabled(fun() ->
Now = erlang:system_time(millisecond),
Events = lists:duplicate(?TEST_GATEWAY_RATE_LIMIT_MAX_EVENTS, Now - 1000),
State = (gateway_handler:new_state())#{
rate_limit_state => #{events => Events, op_events => #{}}
},
?assertMatch(
{rate_limited, _}, gateway_handler_rate_limit:check_rate_limit(State, heartbeat)
)
end).
check_presence_rate_limit_soft_drops_only_presence_test() ->
with_rate_limits_enabled(fun() ->
Now = erlang:system_time(millisecond),
Events = lists:duplicate(5, Now - 1000),
State = (gateway_handler:new_state())#{
rate_limit_state => #{
events => [], op_events => #{presence_update => Events}
}
},
{opcode_rate_limited, NewState} = gateway_handler_rate_limit:check_rate_limit(
State, presence_update
),
RateLimitState = maps:get(rate_limit_state, NewState),
?assertEqual(1, length(maps:get(events, RateLimitState)))
end).
enqueue_voice_update_keeps_latest_update_for_connection_test() ->
Queue0 = queue:new(),
Data1 = #{
<<"guild_id">> => <<"1">>, <<"connection_id">> => <<"conn-1">>, <<"self_mute">> => false
},
Data2 = Data1#{<<"self_mute">> => true},
Queue1 = gateway_handler_voice:enqueue_voice_update(Queue0, Data1),
Queue2 = gateway_handler_voice:enqueue_voice_update(Queue1, Data2),
?assertEqual([Data2], queue:to_list(Queue2)).
enqueue_voice_update_keeps_distinct_connections_test() ->
Queue0 = queue:new(),
Data1 = #{
<<"guild_id">> => <<"1">>, <<"connection_id">> => <<"conn-1">>, <<"self_mute">> => false
},
Data2 = Data1#{<<"connection_id">> => <<"conn-2">>, <<"self_mute">> => true},
Queue1 = gateway_handler_voice:enqueue_voice_update(Queue0, Data1),
Queue2 = gateway_handler_voice:enqueue_voice_update(Queue1, Data2),
?assertEqual([Data1, Data2], queue:to_list(Queue2)).
handle_request_guild_members_queues_latest_request_while_worker_active_test() ->
ExistingWorkerPid = self(),
State = (gateway_handler:new_state())#{request_guild_members_pid => ExistingWorkerPid},
Data = #{<<"guild_ids">> => [<<"1">>, <<"2">>]},
?assertMatch(
{ok, #{request_guild_members_pending := Data}},
gateway_handler_dispatch:handle_request_guild_members(Data, self(), State)
).
handle_request_guild_counts_drops_when_socket_worker_limit_reached_test() ->
Ref = make_ref(),
WorkerPid = self(),
State = (gateway_handler:new_state())#{
request_worker_max => 1,
request_workers => #{
Ref => #{pid => WorkerPid, type => lazy_request, timer => undefined}
}
},
{ok, NewState} = gateway_handler_dispatch:handle_request_guild_counts(
#{}, self(), eqwalizer:dynamic_cast(State)
),
?assertEqual(1, maps:size(maps:get(request_workers, NewState))).
request_worker_down_removes_tracked_worker_test() ->
Ref = make_ref(),
WorkerPid = self(),
State = (gateway_handler:new_state())#{
request_workers => #{
Ref => #{pid => WorkerPid, type => lazy_request, timer => undefined}
}
},
{ok, NewState} = gateway_handler_dispatch:handle_request_worker_down(
Ref, WorkerPid, normal, State
),
?assertEqual(#{}, maps:get(request_workers, NewState)).
request_worker_timeout_removes_and_kills_worker_test() ->
WorkerPid = spawn(fun request_worker_wait_loop/0),
Ref = make_ref(),
State = (gateway_handler:new_state())#{
request_workers => #{
Ref => #{pid => WorkerPid, type => lazy_request, timer => undefined}
}
},
{ok, NewState} = gateway_handler_dispatch:handle_request_worker_timeout(
Ref, lazy_request, State
),
ok = gateway_retry_timer:wait(10),
?assertEqual(#{}, maps:get(request_workers, NewState)),
?assertNot(erlang:is_process_alive(WorkerPid)).
validate_presence_data_valid_test() ->
Data = #{<<"status">> => <<"online">>, <<"afk">> => false, <<"mobile">> => false},
{ok, Result} = gateway_handler_dispatch:validate_presence_data(Data),
?assertEqual(online, maps:get(status, Result)).
validate_presence_data_missing_status_test() ->
?assertEqual(
{error, invalid_presence},
gateway_handler_dispatch:validate_presence_data(#{<<"afk">> => false})
).
validate_presence_data_empty_map_test() ->
?assertEqual(
{error, invalid_presence}, gateway_handler_dispatch:validate_presence_data(#{})
).
validate_presence_data_not_a_map_test() ->
?assertEqual(
{error, invalid_presence}, gateway_handler_dispatch:validate_presence_data(not_a_map())
).
validate_presence_data_invalid_status_string_test() ->
?assertEqual(
{error, invalid_presence},
gateway_handler_dispatch:validate_presence_data(#{
<<"status">> => <<"not_a_real_status">>
})
).
validate_presence_data_integer_status_defaults_to_online_test() ->
{ok, Result} = gateway_handler_dispatch:validate_presence_data(#{<<"status">> => 42}),
?assertEqual(online, maps:get(status, Result)).
validate_presence_data_offline_becomes_invisible_test() ->
{ok, Result} = gateway_handler_dispatch:validate_presence_data(#{
<<"status">> => <<"offline">>
}),
?assertEqual(invisible, maps:get(status, Result)).
validate_resume_data_valid_test() ->
?assertEqual(
{ok, <<"abc">>, <<"sess1">>, 5},
gateway_handler_identify:validate_resume_data(#{
<<"token">> => <<"abc">>, <<"session_id">> => <<"sess1">>, <<"seq">> => 5
})
).
validate_resume_data_missing_token_test() ->
?assertEqual(
{error, missing_required_field},
gateway_handler_identify:validate_resume_data(#{
<<"session_id">> => <<"sess1">>, <<"seq">> => 5
})
).
validate_resume_data_empty_map_test() ->
?assertEqual(
{error, missing_required_field}, gateway_handler_identify:validate_resume_data(#{})
).
validate_resume_data_not_a_map_test() ->
?assertEqual(
{error, invalid_data}, gateway_handler_identify:validate_resume_data(not_a_map())
).
validate_identify_data_empty_map_test() ->
?assertEqual(
{error, missing_required_field}, gateway_handler_identify:validate_identify_data(#{})
).
validate_identify_data_non_map_properties_test() ->
?assertEqual(
{error, invalid_properties},
gateway_handler_identify:validate_identify_data(#{
<<"token">> => <<"abc">>, <<"properties">> => <<"not_a_map">>
})
).
validate_identify_data_negative_flags_test() ->
?assertEqual(
{error, invalid_properties},
gateway_handler_identify:validate_identify_data(#{
<<"token">> => <<"abc">>,
<<"properties">> => #{
<<"os">> => <<"linux">>, <<"browser">> => <<"b">>, <<"device">> => <<"d">>
},
<<"flags">> => -1
})
).
validate_identify_data_accepts_shard_test() ->
?assertMatch(
{ok, <<"abc">>, _Properties, _Presence, [], 0, undefined, {1, 4}},
gateway_handler_identify:validate_identify_data(
valid_identify_data(#{
<<"shard">> => [1, 4]
})
)
).
validate_identify_data_accepts_absent_shard_test() ->
?assertMatch(
{ok, <<"abc">>, _Properties, _Presence, [], 0, undefined, undefined},
gateway_handler_identify:validate_identify_data(valid_identify_data(#{}))
).
validate_identify_data_rejects_invalid_shard_test() ->
?assertEqual(
{error, invalid_shard},
gateway_handler_identify:validate_identify_data(
valid_identify_data(#{
<<"shard">> => [2, 2]
})
)
),
?assertEqual(
{error, invalid_shard},
gateway_handler_identify:validate_identify_data(
valid_identify_data(#{
<<"shard">> => <<"not-a-shard">>
})
)
).
handle_identify_runs_identify_rate_check_at_zero_rollout_test() ->
session_abuse_protection:ensure_tables(),
OldConfig = gateway_rollout_config:get(),
IP = unique_test_peer_ip(<<"zero-rollout-identify">>),
try
persistent_term:put(gateway_rollout_config, OldConfig#{
<<"session_rollout_percentage">> => 0
}),
lists:foreach(
fun(_) ->
?assertEqual(ok, session_abuse_protection:check_identify_rate(IP))
end,
lists:seq(1, ?TEST_IDENTIFY_MAX_PER_IP - 1)
),
{ok, HeldState} = gateway_handler_identify:handle_identify(
valid_identify_data(#{}), IP, new_json_state()
),
cancel_pending_identify_timer(HeldState),
?assertEqual(
{error, identify_rate_limited},
session_abuse_protection:check_identify_rate(IP)
)
after
persistent_term:put(gateway_rollout_config, OldConfig)
end.
handle_session_start_result_invalid_shard_closes_4010_test() ->
{[{close, CloseCode, _Reason}], _NewState} =
gateway_handler_identify:handle_session_start_result(
{error, invalid_shard}, new_json_state()
),
?assertEqual(constants:close_code_to_num(invalid_shard), CloseCode).
handle_session_start_result_sharding_required_closes_4011_test() ->
{[{close, CloseCode, _Reason}], _NewState} =
gateway_handler_identify:handle_session_start_result(
{error, sharding_required}, new_json_state()
),
?assertEqual(constants:close_code_to_num(sharding_required), CloseCode).
identify_with_non_map_payload_closes_test() ->
State = (new_json_state())#{peer_ip => unique_test_peer_ip(<<"198.51.100.61">>)},
{[{close, CloseCode, _Reason}], _NewState} = gateway_handler_dispatch:handle_opcode(
identify, #{<<"d">> => null}, State
),
?assertEqual(constants:close_code_to_num(decode_error), CloseCode).
voice_state_update_with_non_map_payload_closes_test() ->
State = (new_json_state())#{session_pid => self()},
{[{close, CloseCode, _Reason}], _NewState} = gateway_handler_dispatch:handle_opcode(
voice_state_update, #{<<"d">> => <<"not-a-map">>}, State
),
?assertEqual(constants:close_code_to_num(decode_error), CloseCode).
new_json_state() ->
(gateway_handler:new_state())#{
version => 1, encoding => json, compress_ctx => gateway_compress:new_context(none)
}.
heartbeat_test_state() ->
(new_json_state())#{
heartbeat_state => #{
last_ack => erlang:system_time(millisecond),
waiting_for_ack => false
}
}.
valid_identify_data(Extra) ->
maps:merge(
#{
<<"token">> => <<"abc">>,
<<"properties">> => #{
<<"os">> => <<"linux">>,
<<"browser">> => <<"browser">>,
<<"device">> => <<"device">>
}
},
Extra
).
assert_reconnect_close(Result, CloseReason) ->
{[Frame, {close, CloseCode, CloseReason}], _NewState} = Result,
?assertEqual(constants:close_code_to_num(unknown_error), CloseCode),
{_FrameType, EncodedFrame} = Frame,
{ok, DecodedFrame} = gateway_codec:decode(EncodedFrame, json),
?assertEqual(constants:opcode_to_num(reconnect), maps:get(<<"op">>, DecodedFrame)).
assert_pending_identify(Result, Request, SocketPid) ->
{ok, State} = Result,
?assertEqual({Request, SocketPid}, maps:get(pending_identify, State)),
?assertMatch({_TimerRef, _Token}, maps:get(pending_identify_retry_timer, State)),
cancel_pending_identify_timer(State).
assert_held_without_pending(Result) ->
{ok, State} = Result,
?assertEqual(undefined, maps:get(pending_identify, State, undefined)),
?assertEqual(undefined, maps:get(pending_identify_retry_timer, State, undefined)).
not_a_map() ->
eqwalizer:dynamic_cast(not_a_map).
request_worker_wait_loop() ->
receive
stop -> ok
after 60000 ->
ok
end.
cancel_pending_identify_timer(State) ->
case maps:get(pending_identify_retry_timer, State, undefined) of
{TimerRef, _Token} when is_reference(TimerRef) ->
_ = erlang:cancel_timer(TimerRef, [{async, true}, {info, false}]),
ok;
TimerRef when is_reference(TimerRef) ->
_ = erlang:cancel_timer(TimerRef, [{async, true}, {info, false}]),
ok;
_ ->
ok
end.
with_rate_limits_enabled(Fun) ->
OldValue = os:getenv("FLUXER_DISABLE_RATE_LIMITS"),
os:unsetenv("FLUXER_DISABLE_RATE_LIMITS"),
try
Fun()
after
restore_env("FLUXER_DISABLE_RATE_LIMITS", OldValue)
end.
restore_env(Key, false) ->
os:unsetenv(Key);
restore_env(Key, Value) ->
os:putenv(Key, Value).
unique_test_peer_ip(Prefix) ->
Suffix = integer_to_binary(erlang:unique_integer([positive])),
<<Prefix/binary, "-", Suffix/binary>>.