%% SPDX-License-Identifier: AGPL-3.0-or-later -module(guild_voice_move_tests). -typing([eqwalizer]). -include_lib("eunit/include/eunit.hrl"). token_rpc_data() -> #{ <<"token">> => <<"tok">>, <<"endpoint">> => <<"wss://voice.example">>, <<"connectionId">> => <<"new-conn">>, <<"regionId">> => <<"us-east">>, <<"serverId">> => <<"voice-1">> }. test_member(UserId) -> #{ <<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"mute">> => false, <<"deaf">> => false }. existing_voice_state(ConnId, UserId, ChannelIdBin) -> #{ <<"user_id">> => integer_to_binary(UserId), <<"guild_id">> => <<"999">>, <<"channel_id">> => ChannelIdBin, <<"connection_id">> => ConnId, <<"session_id">> => <<"sess1">>, <<"self_mute">> => false, <<"self_deaf">> => true, <<"self_video">> => false, <<"self_stream">> => false, <<"is_mobile">> => false, <<"suppress">> => false, <<"mute">> => false, <<"deaf">> => false, <<"viewer_stream_keys">> => [], <<"e2ee_capable">> => false, <<"member">> => test_member(UserId), <<"version">> => 3 }. base_move_state(VoiceStates) -> #{ id => 999, data => #{ <<"id">> => <<"999">>, <<"guild">> => #{<<"owner_id">> => <<"10">>}, <<"channels">> => [ #{<<"id">> => <<"100">>, <<"type">> => 2}, #{<<"id">> => <<"200">>, <<"type">> => 2} ], <<"members">> => [test_member(10)] }, sessions => #{}, voice_states => VoiceStates, pending_voice_connections => #{} }. move_context() -> #{ user_id => 10, session_id => <<"sess1">>, connection_id => <<"old-conn">>, self_mute => false, self_deaf => true, self_video => false, self_stream => false, is_mobile => false, viewer_stream_keys => undefined, latitude => <<"1.0">>, longitude => <<"2.0">>, e2ee_capable => false, bot => false }. with_token_rpc(Result, Fun) -> drain_stale_dispatches(), meck:new(rpc_client, [passthrough, no_link, non_strict]), meck:expect(rpc_client, call, fun(_Request) -> Result end), try Fun() after meck:unload(rpc_client) end. with_guild_voice_server_stub(Fun) -> meck:new(guild_voice_server, [passthrough, no_link]), meck:expect(guild_voice_server, resolve, fun(_GuildId, GuildPid) -> GuildPid end), try Fun() after meck:unload(guild_voice_server) end. drain_stale_dispatches() -> receive {'$gen_cast', {dispatch, _Event, _Payload}} -> drain_stale_dispatches() after 0 -> ok end. assert_no_dispatch() -> receive {'$gen_cast', {dispatch, Event, Payload}} -> ?assert(false, {unexpected_dispatch, Event, Payload}) after 50 -> ok end. reply_map(Reply) when is_map(Reply) -> Reply. run_client_move(VoiceStates, State) -> {reply, Reply, NewState} = guild_voice_connection_move:handle_client_channel_move( move_context(), 200, test_member(10), <<"old-conn">>, VoiceStates, State ), {Reply, NewState}. client_move_token_failure_preserves_connection_test() -> with_token_rpc({error, timeout}, fun client_move_token_failure_scenario/0). client_move_token_failure_scenario() -> ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = base_move_state(VoiceStates), {Error, NewState} = run_client_move(VoiceStates, State), ?assertMatch({error, _, voice_token_failed}, Error), ?assertEqual( ExistingVS, maps:get(<<"old-conn">>, maps:get(voice_states, NewState)) ), ?assertEqual(#{}, maps:get(pending_voice_connections, NewState)), assert_no_dispatch(). client_move_success_disconnects_and_stores_pending_test() -> with_token_rpc({ok, token_rpc_data()}, fun client_move_success_scenario/0). client_move_success_scenario() -> ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = base_move_state(VoiceStates), {Reply0, NewState} = run_client_move(VoiceStates, State), Reply = reply_map(Reply0), ?assertEqual(true, maps:get(success, Reply)), ?assertEqual(true, maps:get(needs_token, Reply)), ?assertEqual(<<"tok">>, maps:get(token, Reply)), ?assertEqual(<<"new-conn">>, maps:get(connection_id, Reply)), ?assertNot(maps:is_key(<<"old-conn">>, maps:get(voice_states, NewState))), Pending = maps:get(pending_voice_connections, NewState), ?assert(maps:is_key(<<"new-conn">>, Pending)), Meta = maps:get(<<"new-conn">>, Pending), ?assert(is_binary(maps:get(token_nonce, Meta))), ?assert(maps:get(expires_at, Meta) > maps:get(created_at, Meta)), ?assertEqual( ok, guild_voice_connection_pending:validate_pending_nonce_and_expiry( maps:get(token_nonce, Meta), Meta ) ). client_move_reply_voice_state_is_complete_and_coordinate_free_test() -> with_token_rpc({ok, token_rpc_data()}, fun client_move_reply_shape_scenario/0). client_move_reply_shape_scenario() -> ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = base_move_state(VoiceStates), {Reply0, NewState} = run_client_move(VoiceStates, State), Reply = reply_map(Reply0), ReplyVS = maps:get(voice_state, Reply), ?assertNot(maps:is_key(<<"latitude">>, ReplyVS)), ?assertNot(maps:is_key(<<"longitude">>, ReplyVS)), ?assertEqual(<<"200">>, maps:get(<<"channel_id">>, ReplyVS)), ?assertEqual(<<"new-conn">>, maps:get(<<"connection_id">>, ReplyVS)), ?assertEqual(true, maps:get(<<"self_deaf">>, ReplyVS)), assert_complete_voice_state_fields(ReplyVS), PendingVS = maps:get( voice_state, maps:get(<<"new-conn">>, maps:get(pending_voice_connections, NewState)) ), ?assertEqual(<<"1.0">>, maps:get(<<"latitude">>, PendingVS)). assert_complete_voice_state_fields(VS) -> Fields = [ <<"member">>, <<"mute">>, <<"deaf">>, <<"suppress">>, <<"viewer_stream_keys">>, <<"e2ee_capable">>, <<"version">>, <<"session_id">> ], lists:foreach(fun(Field) -> ?assert(maps:is_key(Field, VS), {missing, Field}) end, Fields). moderator_move_reply_includes_session_data_test() -> ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = base_move_state(VoiceStates), {reply, Reply0, NewState} = guild_voice_move_execute:handle_move( #{<<"old-conn">> => ExistingVS}, 200, 10, 10, <<"old-conn">>, VoiceStates, State ), Reply = reply_map(Reply0), ?assertEqual(true, maps:get(success, Reply)), ?assertEqual(true, maps:get(needs_token, Reply)), [SessionInfo] = maps:get(session_data, Reply), ?assertEqual(<<"old-conn">>, maps:get(connection_id, SessionInfo)), ?assertEqual(<<"sess1">>, maps:get(session_id, SessionInfo)), ?assertEqual(true, maps:get(self_deaf, SessionInfo)), ?assertEqual(false, maps:get(e2ee_capable, SessionInfo)), ?assertNot(maps:is_key(<<"old-conn">>, maps:get(voice_states, NewState))). moderator_move_to_unknown_channel_fails_test() -> ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = base_move_state(VoiceStates), {reply, Error, _} = guild_voice_move_execute:handle_move( #{<<"old-conn">> => ExistingVS}, 12345, 10, 10, <<"old-conn">>, VoiceStates, State ), ?assertMatch({error, _, voice_channel_not_found}, Error). moderator_move_without_connections_fails_test() -> State = base_move_state(#{}), {reply, ErrorWithConn, _} = guild_voice_move_execute:handle_move( #{}, 200, 10, 10, <<"old-conn">>, #{}, State ), ?assertMatch({error, _, voice_connection_not_found}, ErrorWithConn), {reply, ErrorNoConn, _} = guild_voice_move_execute:handle_move( #{}, 200, 10, 10, null, #{}, State ), ?assertMatch({error, _, voice_user_not_in_voice}, ErrorNoConn). moderator_disconnect_move_purges_counts_cache_test() -> RegionId = <<"region-move-test">>, VS = (existing_voice_state(<<"move-count-conn">>, 10, <<"100">>))#{ <<"region_id">> => RegionId, <<"server_id">> => <<"server-move-test">> }, _ = voice_state_counts_cache:upsert_voice_state(VS), ?assertEqual(1, region_count(RegionId)), VoiceStates = #{<<"move-count-conn">> => VS}, State = base_move_state(VoiceStates), {reply, Reply0, NewState} = guild_voice_move_execute:handle_move( VoiceStates, null, 10, 20, <<"move-count-conn">>, VoiceStates, State ), Reply = reply_map(Reply0), ?assertEqual(true, maps:get(success, Reply)), ?assertEqual(10, maps:get(user_id, Reply)), ?assertEqual(0, region_count(RegionId)), ?assertNot(maps:is_key(<<"move-count-conn">>, maps:get(voice_states, NewState))). region_count(RegionId) -> Counts = voice_state_counts_cache:get_local_counts(), Regions = maps:get(<<"regions">>, Counts, []), lists:foldl( fun(Entry, Acc) -> add_region_entry_count(Entry, RegionId, Acc) end, 0, Regions ). add_region_entry_count(Entry, RegionId, Acc) -> case maps:get(<<"region_id">>, Entry, undefined) of RegionId -> Acc + maps:get(<<"voice_state_count">>, Entry, 0); _ -> Acc end. guild_server_stub(StateData, TestPid) -> spawn_link(fun() -> guild_server_stub_loop(StateData, TestPid) end). guild_server_stub_loop(StateData, TestPid) -> receive {'$gen_call', From, {get_voice_guild_state}} -> gen:reply(From, undefined), guild_server_stub_loop(StateData, TestPid); {'$gen_call', From, {get_sessions}} -> gen:reply(From, StateData), guild_server_stub_loop(StateData, TestPid); {'$gen_call', From, {store_pending_connection, ConnId, Meta}} -> TestPid ! {stored_pending, ConnId, Meta}, gen:reply(From, ok), guild_server_stub_loop(StateData, TestPid) after 5000 -> ok end. receive_stored_pending() -> receive {stored_pending, ConnId, Meta} -> {ConnId, Meta} after 1000 -> error(pending_connection_not_stored) end. receive_voice_server_update() -> receive {'$gen_cast', {dispatch, voice_server_update, Payload}} -> Payload after 1000 -> error(voice_server_update_not_dispatched) end. send_moderator_move_update(StateData) -> ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), StubPid = guild_server_stub(StateData, self()), SessionInfo = guild_voice_state:extract_session_info_from_voice_state( <<"old-conn">>, ExistingVS ), ok = guild_voice_move_execute:send_single_voice_server_update( 999, 200, SessionInfo, StubPid ), receive_stored_pending(). moderator_move_token_delivery_stores_restorable_pending_test() -> with_token_rpc({ok, token_rpc_data()}, fun() -> with_guild_voice_server_stub(fun moderator_move_pending_scenario/0) end). moderator_move_pending_scenario() -> StateData = (base_move_state(#{}))#{ sessions => #{<<"sess1">> => #{pid => self(), user_id => 10}} }, {ConnId, Meta} = send_moderator_move_update(StateData), ?assertEqual(<<"new-conn">>, ConnId), ?assert(is_binary(maps:get(token_nonce, Meta))), ?assert(is_integer(maps:get(created_at, Meta))), ?assert(maps:get(expires_at, Meta) > maps:get(created_at, Meta)), ?assertEqual(10, maps:get(user_id, Meta)), ?assertEqual(200, maps:get(channel_id, Meta)), ?assertEqual(true, maps:get(self_deaf, Meta)), ?assertEqual( ok, guild_voice_connection_pending:validate_pending_nonce_and_expiry( maps:get(token_nonce, Meta), Meta ) ), ?assertEqual( {error, voice_nonce_mismatch}, guild_voice_connection_pending:validate_pending_nonce_and_expiry( <<"wrong-nonce">>, Meta ) ), Payload = receive_voice_server_update(), ?assertEqual(<<"tok">>, maps:get(<<"token">>, Payload)), ?assertEqual(<<"new-conn">>, maps:get(<<"connection_id">>, Payload)), assert_pending_restores(ConnId, Meta). assert_pending_restores(ConnId, Meta) -> RestoreState = #{ id => 999, sessions => #{}, voice_states => #{}, pending_voice_connections => #{ConnId => Meta} }, {ok, RestoredVoiceStates, _RestoredState} = guild_voice_connection_pending:maybe_restore_pending_connection( ConnId, 200, 10, #{}, RestoreState ), RestoredVS = maps:get(ConnId, RestoredVoiceStates), ?assertEqual(<<"200">>, maps:get(<<"channel_id">>, RestoredVS)), ?assertEqual(<<"10">>, maps:get(<<"user_id">>, RestoredVS)), ?assertEqual(true, maps:get(<<"self_deaf">>, RestoredVS)), ?assert(maps:is_key(<<"member">>, RestoredVS)), ?assert(maps:is_key(<<"version">>, RestoredVS)). moderator_move_pending_sweep_expires_stale_entries_test() -> with_token_rpc({ok, token_rpc_data()}, fun() -> with_guild_voice_server_stub(fun moderator_move_sweep_scenario/0) end). moderator_move_sweep_scenario() -> {ConnId, Meta} = send_moderator_move_update(base_move_state(#{})), ExpiredMeta = Meta#{ created_at => maps:get(created_at, Meta) - 600000, expires_at => maps:get(expires_at, Meta) - 600000 }, SweepState = #{ id => 999, sessions => #{}, voice_states => #{}, pending_voice_connections => #{ConnId => ExpiredMeta} }, SweptState = guild_voice_connection_pending:sweep_expired_pending_joins(SweepState), ?assertEqual(#{}, maps:get(pending_voice_connections, SweptState)), ?assertEqual( {error, voice_pending_expired}, guild_voice_connection_pending:validate_pending_nonce_and_expiry( maps:get(token_nonce, ExpiredMeta), ExpiredMeta ) ). private_member(UserId) -> #{ <<"user">> => #{<<"id">> => integer_to_binary(UserId)}, <<"roles">> => [], <<"mute">> => false, <<"deaf">> => false }. private_move_state(VoiceStates, VirtualAccess) -> #{ id => 999, data => #{ <<"id">> => <<"999">>, <<"guild">> => #{<<"owner_id">> => <<"20">>}, <<"roles">> => [#{<<"id">> => <<"999">>, <<"permissions">> => <<"0">>}], <<"members">> => [private_member(10), private_member(20)], <<"channels">> => [ #{<<"id">> => <<"100">>, <<"type">> => 2, <<"permission_overwrites">> => []}, #{<<"id">> => <<"200">>, <<"type">> => 2, <<"permission_overwrites">> => []} ] }, sessions => #{<<"s1">> => #{pid => self(), user_id => 10}}, voice_states => VoiceStates, pending_voice_connections => #{}, virtual_channel_access => VirtualAccess }. receive_channel_dispatch(Event) -> receive {'$gen_cast', {dispatch, Event, Payload}} -> Payload after 1000 -> error({dispatch_not_received, Event}) end. moderator_move_into_private_channel_grants_lease_test() -> drain_stale_dispatches(), ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = private_move_state(VoiceStates, #{}), {reply, Reply0, NewState} = guild_voice_move_execute:handle_move( VoiceStates, 200, 10, 20, <<"old-conn">>, VoiceStates, State ), Reply = reply_map(Reply0), ?assertEqual(true, maps:get(success, Reply)), ?assertEqual(true, maps:get(needs_token, Reply)), ?assert(guild_virtual_channel_access:has_virtual_access(10, 200, NewState)), ?assert(guild_virtual_channel_access:is_move_pending(10, 200, NewState)), Payload = receive_channel_dispatch(channel_create), ?assertEqual(<<"200">>, maps:get(<<"id">>, Payload)), ?assertEqual(<<"999">>, maps:get(<<"guild_id">>, Payload)). moderator_move_out_of_leased_channel_revokes_lease_test() -> drain_stale_dispatches(), ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = private_move_state(VoiceStates, #{10 => sets:from_list([100])}), {reply, Reply0, NewState} = guild_voice_move_execute:handle_move( VoiceStates, 200, 10, 20, <<"old-conn">>, VoiceStates, State ), ?assertEqual(true, maps:get(success, reply_map(Reply0))), ?assertNot(guild_virtual_channel_access:has_virtual_access(10, 100, NewState)), ?assert(guild_virtual_channel_access:has_virtual_access(10, 200, NewState)), DeletePayload = receive_channel_dispatch(channel_delete), ?assertEqual(<<"100">>, maps:get(<<"id">>, DeletePayload)), ?assertEqual(<<"999">>, maps:get(<<"guild_id">>, DeletePayload)). moderator_move_without_lease_dispatches_no_channel_delete_test() -> drain_stale_dispatches(), ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = (base_move_state(VoiceStates))#{ sessions => #{<<"s1">> => #{pid => self(), user_id => 10}} }, {reply, Reply0, _NewState} = guild_voice_move_execute:handle_move( VoiceStates, 200, 10, 10, <<"old-conn">>, VoiceStates, State ), ?assertEqual(true, maps:get(success, reply_map(Reply0))), assert_no_channel_delete(). assert_no_channel_delete() -> receive {'$gen_cast', {dispatch, channel_delete, Payload}} -> ?assert(false, {unexpected_channel_delete, Payload}) after 50 -> ok end. self_move_out_of_leased_channel_revokes_lease_test() -> with_token_rpc({ok, token_rpc_data()}, fun self_move_revokes_lease_scenario/0). self_move_revokes_lease_scenario() -> ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>), VoiceStates = #{<<"old-conn">> => ExistingVS}, State = (base_move_state(VoiceStates))#{ sessions => #{<<"s1">> => #{pid => self(), user_id => 10}}, virtual_channel_access => #{10 => sets:from_list([100])} }, {_Reply, NewState} = run_client_move(VoiceStates, State), ?assertNot(guild_virtual_channel_access:has_virtual_access(10, 100, NewState)), DeletePayload = receive_channel_dispatch(channel_delete), ?assertEqual(<<"100">>, maps:get(<<"id">>, DeletePayload)).