feat(voice): add opt-in peer-to-peer calls and country rollouts (#3367)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 15:45:17 +02:00
1 parent e5a568acca
commit bef5e33eec
215 files changed
+15840 -2693

No files matched your search

+697
View File
@@ -0,0 +1,697 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(call_p2p_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
agreed_joiners_form_a_mesh_without_pending_connections_test() ->
with_stubs(fun() ->
State = join_all([{1, true}, {2, true}], call_state()),
?assertEqual([true, true], p2p_flags(State)),
?assertEqual(#{}, maps:get(pending_connections, State)),
?assertEqual([], drain_voice_server_updates())
end).
sfu_join_keeps_its_pending_connection_test() ->
with_stubs(fun() ->
State = join_all([{1, false}], call_state()),
?assert(maps:is_key(connection_id(1), maps:get(pending_connections, State)))
end).
race_sfu_join_after_p2p_start_is_rejected_test() ->
with_stubs(fun() ->
State = join_all([{1, true}], call_state()),
?assertEqual(
{reply, {error, voice_p2p_consent_required}, State}, join(2, false, State)
),
?assertEqual([], drain_voice_server_updates())
end).
race_p2p_join_after_sfu_start_is_rejected_test() ->
with_stubs(fun() ->
State = join_all([{2, false}], call_state()),
?assertEqual(
{reply, {error, voice_p2p_consent_required}, State}, join(1, true, State)
),
?assertEqual([], drain_voice_server_updates())
end).
race_async_join_producing_mixed_states_is_rejected_test() ->
with_stubs(fun() ->
State = join_all([{1, true}], call_state()),
?assertEqual(
{noreply, State},
call_voice:handle_join_async(
2, voice_state(2, false), session_id(2), erlang:get(call_p2p_sink), State
)
),
receive
{call_join_result, 1, Result} ->
?assertEqual({error, voice_p2p_consent_required}, Result)
after 1000 ->
error(call_join_result_not_sent)
end,
?assertEqual([], drain_voice_server_updates())
end).
fifth_joiner_over_the_cap_is_rejected_test() ->
with_stubs(fun() ->
Mesh = join_all([{UserId, true} || UserId <- [1, 2, 3, 4]], call_state()),
?assertEqual([true, true, true, true], p2p_flags(Mesh)),
?assertEqual({reply, {error, voice_channel_full}, Mesh}, join(5, true, Mesh)),
?assertEqual([], drain_voice_server_updates())
end).
race_join_past_the_configured_cap_is_rejected_test() ->
with_stubs(fun() ->
Mesh = join_all([{1, true}, {2, true}], call_state()),
{reply, ok, Full} = call:handle_call(capped_join(3, 3), from(), Mesh),
?assertEqual([true, true, true], p2p_flags(Full)),
?assertEqual(
{reply, {error, voice_channel_full}, Full},
call:handle_call(capped_join(4, 3), from(), Full)
),
?assertNot(maps:is_key(4, maps:get(voice_states, Full))),
?assertEqual(#{}, maps:get(pending_connections, Full)),
?assertEqual([], drain_voice_server_updates())
end).
join_without_a_cap_falls_back_to_the_hard_ceiling_test() ->
with_stubs(fun() ->
Mesh = join_all([{UserId, true} || UserId <- [1, 2, 3]], call_state()),
{reply, ok, Full} = call:handle_call(
{join, 4, voice_state(4, true), session_id(4), erlang:get(call_p2p_sink),
connection_id(4)},
from(),
Mesh
),
?assertEqual([true, true, true, true], p2p_flags(Full))
end).
session_p2p_grant_passes_the_cap_to_the_call_join_test() ->
with_stubs(fun() ->
meck:new(gateway_rpc_call_lookup, [passthrough, no_link]),
try
{reply, #{success := true}, _State} = dm_join(true, [voice_state(20, true)]),
?assertMatch(
[{join, 10, #{<<"p2p">> := true}, <<"sess">>, _Pid, _ConnectionId, 3}],
[
Request
|| {_Caller, {gateway_rpc_call_lookup, safe_gen_server_call, [_, Request, _]},
_Result} <- meck:history(gateway_rpc_call_lookup),
element(1, Request) =:= join
]
)
after
meck:unload(gateway_rpc_call_lookup)
end
end).
rejected_join_rolls_back_without_retrying_test() ->
with_stubs(fun() ->
erlang:put(call_p2p_join_reply, {error, voice_p2p_consent_required}),
ok = dm_voice_token:join_or_create_call(
100, 10, voice_state(10, false), <<"sess">>, self(), undefined
),
?assertEqual(
[
{voice_rejected, voice_p2p_consent_required},
{call_force_disconnect, 100, connection_id(10)}
],
drain_session_casts()
),
?assertEqual(1, meck:num_calls(call_manager, lookup, '_'))
end).
explicit_p2p_false_update_converts_test() ->
with_stubs(fun() ->
State0 = join_all([{1, true}, {2, true}], call_state()),
{reply, ok, State1} = call:handle_call(
{update_voice_state, 1, voice_state(1, false)}, from(), State0
),
?assertEqual([false, false], p2p_flags(State1)),
?assertMatch(#{<<"p2p">> := false}, receive_voice_state_update(connection_id(2))),
?assertEqual(
[connection_id(1), connection_id(2)], lists:sort(drain_voice_server_updates())
)
end).
update_keeping_p2p_keeps_the_mesh_test() ->
with_stubs(fun() ->
State0 = join_all([{1, true}, {2, true}], call_state()),
{reply, ok, State1} = call:handle_call(
{update_voice_state, 1, (voice_state(1, true))#{<<"self_mute">> => true}},
from(),
State0
),
?assertEqual([true, true], p2p_flags(State1)),
?assertEqual([], drain_voice_server_updates())
end).
stale_p2p_update_after_conversion_stays_sfu_test() ->
with_stubs(fun() ->
Mesh = join_all([{1, true}, {2, true}], call_state()),
{reply, ok, State0} = call:handle_call(
{update_voice_state, 2, voice_state(2, false)}, from(), Mesh
),
?assertEqual(2, length(drain_voice_server_updates())),
ok = flush_user_dispatches(),
{reply, ok, State1} = call:handle_call(
{update_voice_state, 1, voice_state(1, true)}, from(), State0
),
?assertEqual([false, false], p2p_flags(State1)),
?assertMatch(#{<<"p2p">> := false}, receive_voice_state_update(connection_id(1))),
?assertEqual([], drain_voice_server_updates())
end).
same_region_update_converts_a_mesh_test() ->
with_stubs(fun() ->
State0 = join_all([{1, true}, {2, true}], (call_state())#{region => <<"us-east">>}),
ok = flush_user_dispatches(),
{reply, ok, State1} = call:handle_call({update_region, <<"us-east">>}, from(), State0),
?assertEqual([false, false], p2p_flags(State1)),
?assertEqual(
[connection_id(1), connection_id(2)], lists:sort(drain_voice_server_updates())
),
?assertEqual(
[<<"us-east">>, <<"us-east">>],
[maps:get(<<"rtc_region">>, Request) || Request <- token_requests()]
),
?assertMatch(
#{voice_states := [#{<<"p2p">> := false}, #{<<"p2p">> := false}]},
receive_user_dispatch(call_update)
)
end).
same_region_update_leaves_an_sfu_call_alone_test() ->
with_stubs(fun() ->
State0 = join_all([{1, false}, {2, false}], (call_state())#{region => <<"us-east">>}),
{reply, ok, _State1} = call:handle_call({update_region, <<"us-east">>}, from(), State0),
?assertEqual([], drain_voice_server_updates())
end).
call_event_and_handoff_keep_the_p2p_flag_test() ->
with_stubs(fun() ->
State = join_all([{1, true}], call_state()),
?assertMatch(
#{voice_states := [#{<<"p2p">> := true}]}, call_state:build_call_event(State)
),
Restored = call_handoff:restore_state(call_handoff:export_state(State)),
?assertEqual([true], p2p_flags(Restored))
end).
signal_relay_allowed_test() ->
with_stubs(fun() ->
State = join_all([{1, true}, {2, true}], call_state()),
Data = #{<<"type">> => <<"answer">>, <<"sdp">> => <<"v=0">>},
{noreply, State} = call:handle_cast(
{voice_signal, session_id(1), connection_id(2), Data}, State
),
?assertEqual(
#{
<<"channel_id">> => <<"1">>,
<<"from">> => connection_id(1),
<<"user_id">> => <<"1">>,
<<"data">> => Data
},
receive_voice_signal()
)
end).
signal_relay_denied_test() ->
with_stubs(fun() ->
Mesh = join_all([{1, true}, {2, true}], call_state()),
Data = #{<<"type">> => <<"candidate">>},
Denied = [
{voice_signal, session_id(9), connection_id(2), Data},
{voice_signal, session_id(1), connection_id(1), Data},
{voice_signal, session_id(1), <<"unknown">>, Data}
],
[{noreply, Mesh} = call:handle_cast(Signal, Mesh) || Signal <- Denied],
{reply, ok, Sfu} = call:handle_call({update_region, <<"us-east">>}, from(), Mesh),
{noreply, Sfu} = call:handle_cast(
{voice_signal, session_id(1), connection_id(2), Data}, Sfu
),
assert_no_voice_signal()
end).
session_p2p_start_without_a_call_test() ->
with_stubs(fun() ->
{reply, #{success := true, connection_id := ConnectionId}, State} = dm_join(
true, not_found
),
?assertMatch(
#{
<<"p2p">> := true,
<<"p2p_initiator">> := true,
<<"p2p_participant_count">> := 1,
<<"country_code">> := <<"SE">>
},
lists:last(token_requests())
),
?assertEqual(
true, maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, State)))
),
Update = receive_session_voice_server_update(),
?assertEqual(
#{
<<"p2p">> => true,
<<"ice_servers">> => [#{<<"urls">> => [<<"stun:stun.example:3478">>]}],
<<"connection_id">> => ConnectionId,
<<"channel_id">> => <<"100">>
},
Update
)
end).
session_p2p_join_of_an_existing_mesh_test() ->
with_stubs(fun() ->
{reply, #{success := true}, _State} = dm_join(true, [voice_state(20, true)]),
?assertMatch(
#{
<<"p2p">> := true,
<<"p2p_initiator">> := false,
<<"p2p_participant_count">> := 2
},
lists:last(token_requests())
),
?assertMatch(#{<<"p2p">> := true}, receive_session_voice_server_update())
end).
session_join_from_a_second_device_counts_the_user_once_test() ->
with_stubs(fun() ->
{reply, #{success := true}, _State} = dm_join(
true, [voice_state(20, true), voice_state(10, true)]
),
?assertMatch(
#{
<<"p2p">> := true,
<<"p2p_initiator">> := false,
<<"p2p_participant_count">> := 2
},
lists:last(token_requests())
)
end).
session_join_of_an_sfu_call_does_not_ask_for_p2p_test() ->
with_stubs(fun() ->
{reply, #{success := true, connection_id := ConnectionId}, State} = dm_join(
true, [voice_state(20, false)]
),
?assertNot(maps:is_key(<<"p2p">>, lists:last(token_requests()))),
?assertEqual(
false, maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, State)))
),
Update = receive_session_voice_server_update(),
?assertEqual(<<"tok">>, maps:get(<<"token">>, Update)),
?assertNot(maps:is_key(<<"p2p">>, Update))
end).
session_join_of_a_mesh_without_agreement_is_rejected_test() ->
with_stubs(fun() ->
?assertMatch(
{reply, {error, permission_denied, voice_p2p_consent_required}, #{
dm_voice_states := #{}
}},
dm_join_reply(undefined, [voice_state(20, true)])
),
?assertEqual([], token_requests()),
assert_no_session_voice_server_update()
end).
session_bot_join_of_a_mesh_is_rejected_test() ->
with_stubs(fun() ->
erlang:put(call_p2p_call_voice_states, [voice_state(20, true)]),
State = dm_session_state(#{}),
?assertMatch(
{reply, {error, permission_denied, voice_p2p_consent_required}, _State},
dm_voice_token:get_dm_voice_token_and_create_state(
(dm_request(true, State))#{bot => true}
)
)
end).
session_join_of_a_full_mesh_is_rejected_test() ->
with_stubs(fun() ->
Mesh = [voice_state(UserId, true) || UserId <- [20, 21, 22, 23]],
?assertMatch(
{reply, {error, permission_denied, voice_channel_full}, _State},
dm_join_reply(true, Mesh)
),
?assertEqual([], token_requests())
end).
session_join_past_the_configured_cap_is_rejected_as_full_test() ->
with_stubs(fun() ->
erlang:put(call_p2p_stub_mode, channel_full),
?assertMatch(
{reply, {error, permission_denied, voice_channel_full}, #{dm_voice_states := #{}}},
dm_join_reply(true, [voice_state(20, true), voice_state(21, true)])
),
?assertMatch(
#{
<<"p2p">> := true,
<<"p2p_initiator">> := false,
<<"p2p_participant_count">> := 3
},
lists:last(token_requests())
),
assert_no_session_voice_server_update()
end).
session_declined_grant_into_a_mesh_is_rejected_test() ->
with_stubs(fun() ->
erlang:put(call_p2p_stub_mode, decline),
?assertMatch(
{reply, {error, voice_error, voice_p2p_unavailable}, #{dm_voice_states := #{}}},
dm_join_reply(true, [voice_state(20, true)])
),
?assertMatch(
#{<<"p2p">> := true, <<"p2p_initiator">> := false}, lists:last(token_requests())
),
assert_no_session_voice_server_update()
end).
session_declined_grant_without_a_call_joins_sfu_test() ->
with_stubs(fun() ->
erlang:put(call_p2p_stub_mode, decline),
{reply, #{success := true, connection_id := ConnectionId}, State} = dm_join(
true, not_found
),
?assertEqual(
false, maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, State)))
),
?assertEqual(<<"tok">>, maps:get(<<"token">>, receive_session_voice_server_update()))
end).
session_update_with_explicit_p2p_false_clears_the_flag_test() ->
with_stubs(fun() ->
?assertEqual(false, dm_updated_p2p(false)),
?assertEqual(true, dm_updated_p2p(undefined)),
?assertEqual(true, dm_updated_p2p(true))
end).
dm_updated_p2p(P2p) ->
Existing = (voice_state(10, true))#{
<<"channel_id">> => <<"100">>, <<"session_id">> => <<"sess">>
},
ConnectionId = connection_id(10),
State = dm_session_state(#{ConnectionId => Existing}),
{reply, #{success := true}, NewState} = dm_voice_connect:handle_dm_connect_or_update(
(dm_request(P2p, State))#{
connection_id => ConnectionId, viewer_stream_keys => undefined
}
),
maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, NewState))).
dm_join_reply(P2p, CallVoiceStates) ->
erlang:put(call_p2p_call_voice_states, CallVoiceStates),
State = dm_session_state(#{}),
dm_voice_token:get_dm_voice_token_and_create_state(dm_request(P2p, State)).
dm_join(P2p, CallVoiceStates) ->
Reply = dm_join_reply(P2p, CallVoiceStates),
receive
{'$gen_cast', {call_monitor, 100, _CallPid}} -> Reply
after 1000 ->
error(call_not_joined)
end.
dm_session_state(VoiceStates) ->
#{
dm_voice_states => VoiceStates,
channels => #{100 => #{<<"type">> => 1, <<"recipient_ids">> => [20]}},
user_id => 10,
id => <<"sess">>,
session_pid => erlang:get(call_p2p_sink)
}.
dm_request(P2p, State) ->
#{
user_id => 10,
channel_id => 100,
session_id => <<"sess">>,
connection_id => null,
self_mute => false,
self_deaf => false,
self_video => false,
self_stream => false,
viewer_stream_keys => [],
is_mobile => false,
latitude => null,
longitude => null,
e2ee_capable => false,
bot => false,
p2p => P2p,
country_code => <<"SE">>,
voice_states => maps:get(dm_voice_states, State),
state => State
}.
join_all(Joins, State0) ->
lists:foldl(
fun({UserId, P2p}, State) ->
{reply, ok, NewState} = join(UserId, P2p, State),
NewState
end,
State0,
Joins
).
join(UserId, P2p, State) ->
call_voice:handle_join_internal(
UserId,
voice_state(UserId, P2p),
session_id(UserId),
erlang:get(call_p2p_sink),
connection_id(UserId),
undefined,
State
).
capped_join(UserId, MaxParticipants) ->
{join, UserId, voice_state(UserId, true), session_id(UserId), erlang:get(call_p2p_sink),
connection_id(UserId), MaxParticipants}.
p2p_flags(State) ->
[
maps:get(<<"p2p">>, VoiceState)
|| {_UserId, VoiceState} <- lists:keysort(1, maps:to_list(maps:get(voice_states, State)))
].
voice_state(UserId, P2p) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => <<"1">>,
<<"connection_id">> => connection_id(UserId),
<<"session_id">> => session_id(UserId),
<<"p2p">> => P2p
}.
connection_id(UserId) ->
<<"conn-", (integer_to_binary(UserId))/binary>>.
session_id(UserId) ->
<<"sess-", (integer_to_binary(UserId))/binary>>.
from() ->
{self(), make_ref()}.
call_state() ->
#{
channel_id => 1,
message_id => 1,
region => undefined,
ringing => [],
pending_ringing => [],
recipients => [1],
voice_states => #{},
sessions => #{},
pending_connections => #{},
initiator_ready => true,
ringing_timers => #{},
idle_timer => undefined,
created_at => 0,
participants_history => sets:new(),
last_call_event => undefined
}.
token_requests() ->
[
Request
|| {_Pid, {rpc_client, call, [#{<<"type">> := <<"voice_get_token">>} = Request]}, _Result} <-
meck:history(rpc_client)
].
drain_voice_server_updates() ->
receive
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
?assertEqual(<<"tok">>, maps:get(<<"token">>, Payload)),
?assertNot(maps:is_key(<<"p2p">>, Payload)),
[maps:get(<<"connection_id">>, Payload) | drain_voice_server_updates()]
after 200 ->
[]
end.
receive_session_voice_server_update() ->
receive
{'$gen_cast', {dispatch, voice_server_update, Payload}} -> Payload
after 1000 ->
error(voice_server_update_not_dispatched)
end.
assert_no_session_voice_server_update() ->
receive
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
error({unexpected_voice_server_update, Payload})
after 100 ->
ok
end.
drain_session_casts() ->
receive
{'$gen_cast', Cast} -> [Cast | drain_session_casts()]
after 100 ->
[]
end.
receive_voice_state_update(ConnectionId) ->
case receive_user_dispatch(voice_state_update) of
#{<<"connection_id">> := ConnectionId} = VoiceState -> VoiceState;
_Other -> receive_voice_state_update(ConnectionId)
end.
receive_user_dispatch(Event) ->
receive
{user_dispatch, Event, Data} -> Data
after 1000 ->
error({user_dispatch_not_received, Event})
end.
flush_user_dispatches() ->
receive
{user_dispatch, _Event, _Data} -> flush_user_dispatches()
after 100 ->
ok
end.
receive_voice_signal() ->
receive
{'$gen_cast', {dispatch, voice_signal, Payload}} -> Payload
after 1000 ->
error(voice_signal_not_dispatched)
end.
assert_no_voice_signal() ->
receive
{'$gen_cast', {dispatch, voice_signal, Payload}} ->
error({unexpected_voice_signal, Payload})
after 100 ->
ok
end.
with_stubs(Fun) ->
Test = self(),
Sink = spawn(fun() -> sink(Test) end),
erlang:put(call_p2p_sink, Sink),
erlang:erase(call_p2p_call_voice_states),
erlang:erase(call_p2p_stub_mode),
erlang:erase(call_p2p_join_reply),
meck:new(rpc_client, [passthrough, no_link, non_strict]),
meck:expect(rpc_client, call, fun rpc_reply/1),
meck:new(presence_manager, [passthrough, no_link]),
meck:expect(presence_manager, dispatch_to_user, fun(_UserId, Event, Data) ->
Sink ! {user_dispatch, Event, Data},
ok
end),
CallPid = spawn(fun call_stub/0),
meck:new(call_manager, [passthrough, no_link]),
meck:expect(call_manager, lookup, fun(_ChannelId) ->
case erlang:get(call_p2p_call_voice_states) of
not_found ->
{error, not_found};
VoiceStates when is_list(VoiceStates) ->
CallPid ! {voice_states, VoiceStates},
{ok, CallPid};
undefined ->
CallPid ! {join_reply, erlang:get(call_p2p_join_reply)},
{ok, CallPid}
end
end),
try
Fun()
after
CallPid ! stop,
meck:unload(call_manager),
meck:unload(presence_manager),
meck:unload(rpc_client),
stop_sink(Sink)
end.
sink(Test) ->
receive
Message ->
Test ! Message,
sink(Test)
after 30000 ->
ok
end.
stop_sink(Sink) ->
exit(Sink, kill),
flush_mailbox().
flush_mailbox() ->
receive
_Message -> flush_mailbox()
after 0 ->
ok
end.
call_stub() ->
call_stub([], ok).
call_stub(VoiceStates, JoinReply) ->
receive
{voice_states, NewVoiceStates} ->
call_stub(NewVoiceStates, JoinReply);
{join_reply, undefined} ->
call_stub(VoiceStates, JoinReply);
{join_reply, NewJoinReply} ->
call_stub(VoiceStates, NewJoinReply);
{'$gen_call', From, {get_state}} ->
gen_server:reply(From, {ok, #{voice_states => VoiceStates, region => undefined}}),
call_stub(VoiceStates, JoinReply);
{'$gen_call', From, _Request} ->
gen_server:reply(From, JoinReply),
call_stub(VoiceStates, JoinReply);
stop ->
ok;
_Other ->
call_stub(VoiceStates, JoinReply)
after 30000 ->
ok
end.
rpc_reply(#{<<"type">> := <<"voice_get_token">>} = Request) ->
ConnectionId = maps:get(<<"connection_id">>, Request, <<"conn-new">>),
case {maps:get(<<"p2p">>, Request, false), erlang:get(call_p2p_stub_mode)} of
{true, channel_full} ->
{ok, #{<<"p2p">> => false, <<"declineReason">> => <<"channel_full">>}};
{true, Mode} when Mode =/= decline ->
{ok, #{
<<"p2p">> => true,
<<"connectionId">> => ConnectionId,
<<"iceServers">> => [#{<<"urls">> => [<<"stun:stun.example:3478">>]}],
<<"maxParticipants">> => 3
}};
_ ->
{ok, #{
<<"token">> => <<"tok">>,
<<"endpoint">> => <<"wss://voice.example">>,
<<"connectionId">> => ConnectionId
}}
end;
rpc_reply(_Request) ->
{ok, #{}}.
+2 -2
View File
@@ -86,7 +86,7 @@ join_from_ringing_adds_voice_state_test() ->
initiator_ready => true
}),
{reply, ok, NewState} = call_voice:handle_join_internal(
UserId, VoiceState, SessionId, self(), ConnectionId, State
UserId, VoiceState, SessionId, self(), ConnectionId, undefined, State
),
?assert(maps:is_key(UserId, maps:get(voice_states, NewState))),
?assertNot(lists:member(UserId, maps:get(ringing, NewState))).
@@ -165,7 +165,7 @@ leave_then_rejoin_keeps_voice_state_test() ->
)
),
{reply, ok, Rejoined} = call_voice:handle_join_internal(
UserId, VoiceState, SessionId, self(), <<"conn-42">>, PostLeave
UserId, VoiceState, SessionId, self(), <<"conn-42">>, undefined, PostLeave
),
?assert(maps:is_key(UserId, maps:get(voice_states, Rejoined))).
@@ -263,6 +263,26 @@ check_presence_rate_limit_soft_drops_only_presence_test() ->
?assertEqual(1, length(maps:get(events, RateLimitState)))
end).
check_voice_signal_rate_limit_allows_trickle_bursts_test() ->
with_rate_limits_enabled(fun() ->
Now = erlang:system_time(millisecond),
State = fun(Count) ->
(gateway_handler:new_state())#{
rate_limit_state => #{
events => [],
op_events => #{voice_signal => lists:duplicate(Count, Now - 1000)}
}
}
end,
?assertMatch(
{ok, _}, gateway_handler_rate_limit:check_rate_limit(State(239), voice_signal)
),
?assertMatch(
{opcode_rate_limited, _},
gateway_handler_rate_limit:check_rate_limit(State(240), voice_signal)
)
end).
enqueue_voice_update_keeps_latest_update_for_connection_test() ->
Queue0 = queue:new(),
Data1 = #{
@@ -506,6 +526,47 @@ voice_state_update_with_non_map_payload_closes_test() ->
),
?assertEqual(constants:close_code_to_num(decode_error), CloseCode).
voice_signal_is_cast_to_the_session_test() ->
State = (new_json_state())#{session_pid => self()},
Data = #{<<"channel_id">> => <<"100">>, <<"to">> => <<"conn-b">>, <<"data">> => #{}},
?assertEqual(
{ok, State},
gateway_handler_dispatch:handle_opcode(voice_signal, #{<<"d">> => Data}, State)
),
receive
{'$gen_cast', {voice_signal, Data}} -> ok
after 1000 ->
error(voice_signal_not_cast)
end.
voice_signal_with_non_map_payload_is_dropped_test() ->
State = (new_json_state())#{session_pid => self()},
?assertEqual(
{ok, State},
gateway_handler_dispatch:handle_opcode(
voice_signal, #{<<"d">> => <<"not-a-map">>}, State
)
).
websocket_info_gateway_error_emits_an_error_frame_test() ->
{[{text, Frame}], _State} = gateway_handler:websocket_info(
{gateway_error, voice_p2p_consent_required}, new_json_state()
),
?assertEqual(
#{
<<"op">> => 12,
<<"d">> => #{
<<"code">> => <<"VOICE_P2P_CONSENT_REQUIRED">>,
<<"message">> => <<"This call is peer-to-peer and requires agreement to join">>
}
},
json:decode(Frame)
).
voice_signal_opcode_is_thirteen_test() ->
?assertEqual(voice_signal, constants:gateway_opcode(13)),
?assertEqual(13, constants:opcode_to_num(voice_signal)).
websocket_handle_rejects_compressed_frame_past_max_payload_test() ->
{ok, Compressed, _} = gateway_compress:compress(
binary:copy(<<"a">>, constants:max_payload_size() * 64),
@@ -46,6 +46,7 @@ broadcast_payload(VoiceState) ->
<<"suppress">> => false,
<<"viewer_stream_keys">> => [],
<<"e2ee_capable">> => false,
<<"p2p">> => false,
<<"version">> => 0
},
VoiceState
@@ -0,0 +1,681 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_voice_p2p_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(GUILD_ID, 999).
-define(CHANNEL_ID, 100).
-define(OTHER_CHANNEL_ID, 200).
-define(PINNED_CHANNEL_ID, 300).
p2p_start_in_an_empty_channel_test() ->
with_stubs(grant, fun(State0) ->
{Reply, State1} = join(10, true, State0),
ConnectionId = maps:get(connection_id, Reply),
?assertMatch(#{success := true, p2p := true, ice_servers := [_]}, Reply),
?assertNot(maps:is_key(token, Reply)),
?assertNot(maps:is_key(endpoint, Reply)),
?assertEqual(true, p2p_flag(ConnectionId, State1)),
?assertEqual(#{}, maps:get(pending_voice_connections, State1)),
?assertMatch(
#{
<<"p2p">> := true,
<<"p2p_initiator">> := true,
<<"p2p_participant_count">> := 1,
<<"country_code">> := <<"SE">>
},
last_token_request()
),
?assertEqual([{ConnectionId, true}], drain_voice_state_updates())
end).
agreed_joiner_joins_the_mesh_test() ->
with_stubs(grant, fun(State0) ->
{ReplyA, State1} = join(10, true, State0),
{ReplyB, State2} = join(11, true, State1),
?assertMatch(#{p2p := true}, ReplyB),
?assertMatch(
#{
<<"p2p">> := true,
<<"p2p_initiator">> := false,
<<"p2p_participant_count">> := 2
},
last_token_request()
),
?assertEqual(true, p2p_flag(maps:get(connection_id, ReplyA), State2)),
?assertEqual(true, p2p_flag(maps:get(connection_id, ReplyB), State2)),
?assertEqual([], served_voice_server_updates(State2))
end).
non_agreeing_joiner_is_rejected_and_nobody_is_converted_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
Requests = token_request_count(),
{Reply, State2} = join(12, undefined, State1),
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
?assertEqual(Requests, token_request_count()),
assert_untouched(Mesh, State1, State2)
end).
bot_is_rejected_like_a_non_agreeing_joiner_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
{reply, Reply, State2} = guild_voice_connection:voice_state_update(
(request(12, true))#{bot => true}, State1
),
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
assert_untouched(Mesh, State1, State2)
end).
fifth_joiner_is_rejected_as_full_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11, 12, 13], State0),
?assertEqual(4, length(Mesh)),
Requests = token_request_count(),
{Reply, State2} = join(14, true, State1),
?assertEqual({error, permission_denied, voice_channel_full}, Reply),
?assertEqual(Requests, token_request_count()),
assert_untouched(Mesh, State1, State2)
end).
joiner_past_the_configured_cap_is_rejected_as_full_test() ->
with_stubs({cap, 2}, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
{Reply, State2} = join(12, true, State1),
?assertMatch(
#{
<<"p2p">> := true,
<<"p2p_initiator">> := false,
<<"p2p_participant_count">> := 3
},
last_token_request()
),
?assertEqual({error, permission_denied, voice_channel_full}, Reply),
assert_untouched(Mesh, State1, State2)
end).
mesh_fills_up_to_the_configured_cap_test() ->
with_stubs({cap, 3}, fun(State0) ->
{[_, _, _] = Mesh, State1} = join_mesh([10, 11, 12], State0),
[?assertEqual(true, p2p_flag(ConnectionId, State1)) || ConnectionId <- Mesh],
{Reply, _State2} = join(13, true, State1),
?assertEqual({error, permission_denied, voice_channel_full}, Reply)
end).
declined_grant_into_a_live_mesh_is_rejected_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
erlang:put(p2p_stub_mode, decline),
{Reply, State2} = join(12, true, State1),
?assertMatch(#{<<"p2p">> := true, <<"p2p_initiator">> := false}, last_token_request()),
?assertEqual({error, voice_error, voice_p2p_unavailable}, Reply),
assert_untouched(Mesh, State1, State2)
end).
declined_grant_on_an_empty_channel_joins_sfu_test() ->
with_stubs(decline, fun(State0) ->
{Reply, State1} = join(10, true, State0),
?assertMatch(#{<<"p2p">> := true, <<"p2p_initiator">> := true}, last_token_request()),
?assertMatch(#{success := true, token := _, endpoint := _}, Reply),
?assertNot(maps:is_key(p2p, Reply)),
?assertEqual(#{}, maps:get(voice_states, State1)),
?assertEqual(
[maps:get(connection_id, Reply)],
maps:keys(maps:get(pending_voice_connections, State1))
)
end).
pinned_empty_channel_joins_a_non_agreeing_joiner_on_sfu_test() ->
with_stubs(grant, fun(State0) ->
{reply, Reply, _State1} = guild_voice_connection:voice_state_update(
(request(10, undefined))#{channel_id => ?PINNED_CHANNEL_ID}, State0
),
?assertMatch(#{success := true, token := _}, Reply),
?assertNot(maps:is_key(<<"p2p">>, last_token_request()))
end).
pinned_channel_joins_sfu_when_the_grant_is_declined_test() ->
with_stubs(decline, fun(State0) ->
{reply, Reply, _State1} = guild_voice_connection:voice_state_update(
(request(10, true))#{channel_id => ?PINNED_CHANNEL_ID}, State0
),
?assertMatch(#{success := true, token := _}, Reply)
end).
pending_join_blocks_a_p2p_start_test() ->
with_stubs(grant, fun(State0) ->
{_Reply, State1} = join(10, undefined, State0),
?assertEqual(1, maps:size(maps:get(pending_voice_connections, State1))),
{Reply, _State2} = join(11, true, State1),
?assertMatch(#{token := _}, Reply),
?assertNot(maps:is_key(<<"p2p">>, last_token_request()))
end).
explicit_p2p_false_update_converts_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], State0),
{reply, #{success := true}, State2} = guild_voice_connection:voice_state_update(
(request(10, false))#{connection_id => ConnectionA}, State1
),
assert_converted(Mesh, State2)
end).
explicit_p2p_false_update_is_ignored_in_a_pinned_channel_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], ?PINNED_CHANNEL_ID, State0),
_ = drain_voice_state_updates(),
{reply, #{success := true}, State2} = guild_voice_connection:voice_state_update(
(request(10, false))#{
channel_id => ?PINNED_CHANNEL_ID, connection_id => ConnectionA
},
State1
),
assert_untouched(Mesh, State1, State2)
end).
update_without_p2p_keeps_the_mesh_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
{reply, #{success := true}, State2} = guild_voice_connection:voice_state_update(
(request(10, undefined))#{connection_id => ConnectionA, self_mute => true},
State1
),
?assertEqual(true, p2p_flag(ConnectionA, State2)),
?assertEqual(true, p2p_flag(ConnectionB, State2)),
?assertEqual(
true,
maps:get(<<"self_mute">>, maps:get(ConnectionA, maps:get(voice_states, State2)))
)
end).
region_switch_converts_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
{reply, #{success := true}, State2} = guild_voice_region:switch_voice_region_handler(
#{channel_id => ?CHANNEL_ID}, State1
),
assert_marked_sfu(Mesh, State2),
GuildPid = maps:get(guild_pid, State2),
GuildPid ! {serve, State2},
ok = guild_voice_region:switch_voice_region(?GUILD_ID, ?CHANNEL_ID, GuildPid),
?assertEqual(lists:sort(Mesh), lists:sort(drain_voice_server_updates(length(Mesh))))
end).
region_switch_keeps_a_pinned_channel_p2p_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], ?PINNED_CHANNEL_ID, State0),
_ = drain_voice_state_updates(),
{reply, #{success := true}, State2} = guild_voice_region:switch_voice_region_handler(
#{channel_id => ?PINNED_CHANNEL_ID}, State1
),
GuildPid = maps:get(guild_pid, State2),
GuildPid ! {serve, State2},
ok = guild_voice_region:switch_voice_region(?GUILD_ID, ?PINNED_CHANNEL_ID, GuildPid),
assert_untouched(Mesh, State1, State2)
end).
livekit_restore_into_a_mesh_is_refused_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
Stale = (maps:get(hd(Mesh), maps:get(voice_states, State1)))#{
<<"connection_id">> => <<"stale">>, <<"user_id">> => <<"12">>, <<"p2p">> => false
},
State2 = State1#{
recently_disconnected_voice_states => #{
<<"stale">> => #{
voice_state => Stale, disconnected_at => erlang:system_time(millisecond)
}
}
},
{reply, Reply, State3} =
guild_voice_connection:confirm_voice_connection_from_livekit(
#{connection_id => <<"stale">>}, State2
),
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
?assertNot(maps:is_key(<<"stale">>, maps:get(voice_states, State3))),
assert_untouched(Mesh, State2, State3)
end).
livekit_activation_into_a_mesh_is_refused_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
State2 = guild_voice_connection_pending:store_pending(
<<"late">>,
#{
user_id => 12,
guild_id => ?GUILD_ID,
channel_id => ?CHANNEL_ID,
session_id => session_id(12),
token_nonce => <<"nonce">>,
expires_at => erlang:system_time(millisecond) + 30000
},
State1
),
{reply, Reply, State3} =
guild_voice_connection:confirm_voice_connection_from_livekit(
#{connection_id => <<"late">>, token_nonce => <<"nonce">>}, State2
),
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
?assertEqual(lists:sort(Mesh), lists:sort(maps:keys(maps:get(voice_states, State3)))),
?assertEqual(#{}, maps:get(pending_voice_connections, State3)),
assert_untouched(Mesh, State1, State3)
end).
explicit_switch_converts_without_a_voice_server_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], State0),
Self = self(),
Guild = spawn(fun() ->
{reply, Reply, State2} = guild_voice_connection:voice_state_update(
(request(10, false))#{connection_id => ConnectionA},
maps:remove(guild_pid, State1)
),
Self ! {converted, Reply, State2},
guild_stub_loop(State2)
end),
receive
{converted, Reply, State2} ->
?assertMatch(#{success := true}, Reply),
assert_marked_sfu(Mesh, State2),
?assertEqual(lists:sort(Mesh), lists:sort(drain_voice_server_updates(2)))
after 2000 ->
error(explicit_switch_did_not_convert)
end,
Guild ! stop
end).
moderator_move_into_a_mesh_is_refused_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10, 11], State0),
State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1),
{reply, Reply, State3} = guild_voice:move_member(
move_request(12, ?CHANNEL_ID), State2
),
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
?assert(maps:is_key(<<"moved">>, maps:get(voice_states, State3))),
assert_untouched(Mesh, State2, State3)
end).
moderator_move_into_an_empty_pinned_channel_moves_on_sfu_test() ->
with_stubs(grant, fun(State0) ->
State1 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State0),
{reply, Reply, _State2} = guild_voice:move_member(
move_request(12, ?PINNED_CHANNEL_ID), State1
),
?assertMatch(#{success := true, needs_token := true}, Reply)
end).
moderator_move_out_of_a_mesh_gets_an_sfu_grant_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
{reply, #{success := true, needs_token := true, session_data := SessionData}, State2} =
guild_voice:move_member(move_request(11, ?OTHER_CHANNEL_ID), State1),
?assertEqual([ConnectionA], maps:keys(maps:get(voice_states, State2))),
?assertEqual(true, p2p_flag(ConnectionA, State2)),
GuildPid = maps:get(guild_pid, State2),
GuildPid ! {serve, State2},
ok = guild_voice_move_execute:send_single_voice_server_update(
?GUILD_ID, ?OTHER_CHANNEL_ID, hd(SessionData), GuildPid
),
?assertEqual([ConnectionB], drain_voice_server_updates(1)),
?assertNot(maps:is_key(<<"p2p">>, last_token_request()))
end).
client_move_into_a_mesh_joins_it_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA], State1} = join_mesh([10], State0),
State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1),
{reply, Reply, State3} = guild_voice_connection:voice_state_update(
(request(12, true))#{connection_id => <<"moved">>}, State2
),
?assertMatch(#{success := true, p2p := true, connection_id := <<"moved">>}, Reply),
?assertEqual(true, p2p_flag(<<"moved">>, State3)),
?assertEqual(true, p2p_flag(ConnectionA, State3)),
?assertEqual(#{}, maps:get(pending_voice_connections, State3))
end).
client_move_into_a_mesh_without_agreement_is_rejected_test() ->
with_stubs(grant, fun(State0) ->
{Mesh, State1} = join_mesh([10], State0),
State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1),
{reply, Reply, State3} = guild_voice_connection:voice_state_update(
(request(12, undefined))#{connection_id => <<"moved">>}, State2
),
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
?assertEqual(
<<"200">>,
maps:get(<<"channel_id">>, maps:get(<<"moved">>, maps:get(voice_states, State3)))
),
assert_untouched(Mesh, State2, State3)
end).
signal_relay_allowed_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
Data = #{<<"type">> => <<"offer">>, <<"sdp">> => <<"v=0">>, <<"id">> => 7},
{noreply, State1} = guild_voice_handler:handle_cast(
{voice_signal, signal(<<"sess-10">>, ConnectionB, Data)}, State1
),
?assertEqual(
#{
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>,
<<"from">> => ConnectionA,
<<"user_id">> => <<"10">>,
<<"data">> => Data
},
receive_voice_signal()
)
end).
signal_relay_denied_test() ->
with_stubs(grant, fun(State0) ->
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
Data = #{<<"type">> => <<"candidate">>},
Denied = [
signal(<<"sess-12">>, ConnectionB, Data),
signal(<<"sess-10">>, ConnectionA, Data),
signal(<<"sess-10">>, <<"unknown">>, Data),
(signal(<<"sess-10">>, ConnectionB, Data))#{channel_id => 200}
],
[
{noreply, State1} = guild_voice_handler:handle_cast({voice_signal, Request}, State1)
|| Request <- Denied
],
State2 = guild_voice_region:switch_to_sfu(?CHANNEL_ID, State1),
{noreply, State2} = guild_voice_handler:handle_cast(
{voice_signal, signal(<<"sess-10">>, ConnectionB, Data)}, State2
),
assert_no_voice_signal(),
?assertEqual(2, length(served_voice_server_updates(State2)))
end).
signal(SessionId, To, Data) ->
#{session_id => SessionId, channel_id => ?CHANNEL_ID, to => To, data => Data}.
with_sfu_voice_state(ConnectionId, UserId, ChannelId, State) ->
VoiceState = voice_state_utils:complete_voice_state(#{
<<"guild_id">> => <<"999">>,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"user_id">> => integer_to_binary(UserId),
<<"connection_id">> => ConnectionId,
<<"session_id">> => session_id(UserId),
<<"member">> => member(UserId)
}),
VoiceStates = maps:get(voice_states, State),
State#{voice_states => VoiceStates#{ConnectionId => VoiceState}}.
move_request(UserId, ChannelId) ->
#{
user_id => UserId,
moderator_id => 10,
channel_id => ChannelId,
mute => false,
deaf => false
}.
assert_converted(Mesh, State) ->
assert_marked_sfu(Mesh, State),
?assertEqual(lists:sort(Mesh), lists:sort(served_voice_server_updates(State))).
assert_marked_sfu(Mesh, State) ->
[?assertEqual(false, p2p_flag(ConnectionId, State)) || ConnectionId <- Mesh],
Broadcasts = drain_voice_state_updates(),
[?assert(lists:member({ConnectionId, false}, Broadcasts)) || ConnectionId <- Mesh].
assert_untouched(Mesh, Before, After) ->
VoiceStates = maps:get(voice_states, Before),
[
?assertEqual(
maps:get(ConnectionId, VoiceStates),
maps:get(ConnectionId, maps:get(voice_states, After))
)
|| ConnectionId <- Mesh
],
?assertEqual(
maps:get(pending_voice_connections, Before), maps:get(pending_voice_connections, After)
),
Broadcasts = drain_voice_state_updates(),
[?assertNot(lists:member({ConnectionId, false}, Broadcasts)) || ConnectionId <- Mesh],
?assertEqual([], served_voice_server_updates(After, 0)).
served_voice_server_updates(State) ->
served_voice_server_updates(
State,
maps:size(
voice_state_utils:channel_voice_states(?CHANNEL_ID, maps:get(voice_states, State))
)
).
served_voice_server_updates(State, Expected) ->
maps:get(guild_pid, State) ! {serve, State},
drain_voice_server_updates(Expected).
drain_voice_server_updates(0) ->
receive
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
error({unexpected_voice_server_update, Payload})
after 100 ->
[]
end;
drain_voice_server_updates(Remaining) ->
receive
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
?assertEqual(<<"tok">>, maps:get(<<"token">>, Payload)),
?assertEqual(<<"wss://voice.example">>, maps:get(<<"endpoint">>, Payload)),
?assertNot(maps:is_key(<<"p2p">>, Payload)),
[maps:get(<<"connection_id">>, Payload) | drain_voice_server_updates(Remaining - 1)]
after 300 ->
[]
end.
drain_voice_state_updates() ->
receive
{'$gen_cast', {dispatch, voice_state_update, {pre_encoded, Encoded}}} ->
#{<<"connection_id">> := ConnectionId, <<"p2p">> := P2p} = json:decode(Encoded),
[{ConnectionId, P2p} | drain_voice_state_updates()]
after 200 ->
[]
end.
receive_voice_signal() ->
receive
{'$gen_cast', {dispatch, voice_signal, Payload}} -> Payload
after 1000 ->
error(voice_signal_not_dispatched)
end.
assert_no_voice_signal() ->
receive
{'$gen_cast', {dispatch, voice_signal, Payload}} ->
error({unexpected_voice_signal, Payload})
after 100 ->
ok
end.
p2p_flag(ConnectionId, State) ->
maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(voice_states, State))).
join_mesh(UserIds, State0) ->
join_mesh(UserIds, ?CHANNEL_ID, State0).
join_mesh(UserIds, ChannelId, State0) ->
lists:foldl(
fun(UserId, {ConnectionIds, State}) ->
{reply, #{p2p := true, connection_id := ConnectionId}, NewState} =
guild_voice_connection:voice_state_update(
(request(UserId, true))#{channel_id => ChannelId}, State
),
{ConnectionIds ++ [ConnectionId], NewState}
end,
{[], State0},
UserIds
).
join(UserId, P2p, State) ->
{reply, Reply, NewState} = guild_voice_connection:voice_state_update(
request(UserId, P2p), State
),
{Reply, NewState}.
request(UserId, P2p) ->
#{
user_id => UserId,
channel_id => ?CHANNEL_ID,
session_id => session_id(UserId),
p2p => P2p,
country_code => <<"SE">>
}.
session_id(UserId) ->
<<"sess-", (integer_to_binary(UserId))/binary>>.
last_token_request() ->
lists:last(token_requests()).
token_request_count() ->
length(token_requests()).
token_requests() ->
[
Request
|| {_Pid, {rpc_client, call, [#{<<"type">> := <<"voice_get_token">>} = Request]}, _Result} <-
meck:history(rpc_client)
].
with_stubs(Mode, Fun) ->
erlang:put(p2p_stub_mode, Mode),
meck:new(rpc_client, [passthrough, no_link, non_strict]),
meck:expect(rpc_client, call, fun rpc_reply/1),
meck:new(guild_voice_server, [passthrough, no_link]),
meck:expect(guild_voice_server, resolve, fun(_GuildId, GuildPid) -> GuildPid end),
GuildPid = spawn(fun guild_stub/0),
Test = self(),
Sink = spawn(fun() -> sink(Test) end),
try
Fun(base_state(GuildPid, Sink))
after
GuildPid ! stop,
meck:unload(guild_voice_server),
meck:unload(rpc_client),
stop_sink(Sink)
end.
sink(Test) ->
receive
Message ->
Test ! Message,
sink(Test)
after 30000 ->
ok
end.
stop_sink(Sink) ->
exit(Sink, kill),
flush_mailbox().
flush_mailbox() ->
receive
_Message -> flush_mailbox()
after 0 ->
ok
end.
rpc_reply(#{<<"type">> := <<"voice_get_token">>} = Request) ->
ConnectionId = maps:get(
<<"connection_id">>,
Request,
<<"conn-", (integer_to_binary(erlang:unique_integer([positive])))/binary>>
),
case {maps:get(<<"p2p">>, Request, false), erlang:get(p2p_stub_mode)} of
{true, grant} ->
p2p_grant(ConnectionId);
{true, {cap, Max}} ->
case maps:get(<<"p2p_participant_count">>, Request) > Max of
true -> {ok, #{<<"p2p">> => false, <<"declineReason">> => <<"channel_full">>}};
false -> p2p_grant(ConnectionId)
end;
_ ->
{ok, #{
<<"token">> => <<"tok">>,
<<"endpoint">> => <<"wss://voice.example">>,
<<"connectionId">> => ConnectionId,
<<"regionId">> => <<"us-east">>,
<<"serverId">> => <<"voice-1">>
}}
end;
rpc_reply(_Request) ->
{ok, #{}}.
p2p_grant(ConnectionId) ->
{ok, #{
<<"p2p">> => true,
<<"connectionId">> => ConnectionId,
<<"iceServers">> => [#{<<"urls">> => [<<"stun:stun.example:3478">>]}]
}}.
guild_stub() ->
receive
{serve, State} -> guild_stub_loop(State);
stop -> ok
after 30000 ->
ok
end.
guild_stub_loop(State) ->
receive
{serve, NewState} ->
guild_stub_loop(NewState);
{'$gen_call', From, {get_voice_guild_state}} ->
gen_server:reply(From, State),
guild_stub_loop(State);
{'$gen_call', From, _Request} ->
gen_server:reply(From, ok),
guild_stub_loop(State);
stop ->
ok;
_Other ->
guild_stub_loop(State)
after 30000 ->
ok
end.
base_state(GuildPid, Sink) ->
UserIds = [10, 11, 12, 13, 14],
#{
id => ?GUILD_ID,
guild_pid => GuildPid,
data => #{
<<"id">> => <<"999">>,
<<"guild">> => #{<<"owner_id">> => <<"10">>},
<<"channels">> => [
#{<<"id">> => <<"100">>, <<"type">> => 2, <<"user_limit">> => 0},
#{<<"id">> => <<"200">>, <<"type">> => 2, <<"user_limit">> => 0},
#{
<<"id">> => <<"300">>,
<<"type">> => 2,
<<"user_limit">> => 0,
<<"rtc_p2p">> => true
}
],
<<"members">> => [member(UserId) || UserId <- UserIds]
},
sessions => maps:from_list([
{session_id(UserId), #{user_id => UserId, pid => Sink, pending_connect => false}}
|| UserId <- UserIds
]),
voice_states => #{},
pending_voice_connections => #{},
test_perm_fun => fun(_UserId) ->
constants:view_channel_permission() bor constants:connect_permission()
end
}.
member(UserId) ->
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"mute">> => false,
<<"deaf">> => false
}.
@@ -284,6 +284,40 @@ build_session_data_normalizes_current_user_for_ready_test() ->
?assertEqual(1, maps:get(<<"mention_flags">>, UserData)),
?assertEqual(false, maps:get(<<"is_staff">>, UserData)).
build_session_data_stores_the_geoip_country_code_test() ->
UserDataMap = #{
<<"id">> => <<"123">>,
<<"username">> => <<"tester">>,
<<"discriminator">> => <<"0001">>,
<<"avatar">> => null,
<<"flags">> => 0
},
IdentifyData = #{properties => #{}, token => <<"token">>, presence => null},
Data = fun(CountryCode) ->
#{
<<"guilds">> => [],
<<"user">> => UserDataMap,
<<"user_settings">> => null,
<<"geoip_country_code">> => CountryCode
}
end,
CountryCode = fun(Value) ->
maps:get(
geoip_country_code, build_test_session_data(Data(Value), IdentifyData, UserDataMap)
)
end,
?assertEqual(<<"SE">>, CountryCode(<<"SE">>)),
?assertEqual(undefined, CountryCode(null)),
?assertEqual(
undefined,
maps:get(
geoip_country_code,
build_test_session_data(
maps:remove(<<"geoip_country_code">>, Data(null)), IdentifyData, UserDataMap
)
)
).
build_session_data_filters_guilds_for_identify_shard_test() ->
Guild0 = guild_id_for_shard(0, 4, 0),
Guild1 = guild_id_for_shard(1, 4, 0),
+242
View File
@@ -0,0 +1,242 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(voice_p2p_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
peer(ConnectionId, SessionId, P2p) ->
#{
<<"connection_id">> => ConnectionId,
<<"session_id">> => SessionId,
<<"user_id">> => <<"10">>,
<<"p2p">> => P2p
}.
mesh(Count) ->
[
peer(integer_to_binary(Index), <<"sess-", (integer_to_binary(Index))/binary>>, true)
|| Index <- lists:seq(1, Count)
].
agreed_requires_an_explicit_true_from_a_user_test() ->
?assert(voice_p2p:agreed(true, false)),
?assertNot(voice_p2p:agreed(true, true)),
?assertNot(voice_p2p:agreed(false, false)),
?assertNot(voice_p2p:agreed(undefined, false)),
?assertNot(voice_p2p:agreed(<<"true">>, false)).
declined_requires_an_explicit_false_test() ->
?assert(voice_p2p:declined(false)),
?assertNot(voice_p2p:declined(true)),
?assertNot(voice_p2p:declined(undefined)),
?assertNot(voice_p2p:declined(null)).
channel_mode_test() ->
?assertEqual(empty, voice_p2p:channel_mode([])),
?assertEqual(p2p, voice_p2p:channel_mode(mesh(2))),
?assertEqual(sfu, voice_p2p:channel_mode([peer(<<"a">>, <<"s">>, false), #{}])),
?assertEqual(mixed, voice_p2p:channel_mode([peer(<<"a">>, <<"s">>, false) | mesh(1)])).
pinned_reads_the_channel_flag_test() ->
?assert(voice_p2p:pinned(#{<<"rtc_p2p">> => true})),
?assertNot(voice_p2p:pinned(#{<<"rtc_p2p">> => false})),
?assertNot(voice_p2p:pinned(#{})),
?assertNot(voice_p2p:pinned(undefined)).
join_decision_for_a_live_mesh_test() ->
?assertEqual({p2p, joiner, 2}, voice_p2p:join_decision(true, mesh(1), false, false)),
?assertEqual({p2p, joiner, 4}, voice_p2p:join_decision(true, mesh(3), false, false)),
?assertEqual(
{reject, voice_p2p_consent_required},
voice_p2p:join_decision(false, mesh(3), false, false)
),
?assertEqual(
{reject, voice_p2p_consent_required},
voice_p2p:join_decision(false, mesh(4), false, false)
),
?assertEqual(
{reject, voice_channel_full}, voice_p2p:join_decision(true, mesh(4), false, false)
).
join_decision_for_an_empty_channel_test() ->
?assertEqual({p2p, initiator, 1}, voice_p2p:join_decision(true, [], false, false)),
?assertEqual({p2p, initiator, 1}, voice_p2p:join_decision(true, [], false, true)),
?assertEqual(sfu, voice_p2p:join_decision(false, [], false, false)),
?assertEqual(sfu, voice_p2p:join_decision(false, [], false, true)),
?assertEqual(sfu, voice_p2p:join_decision(true, [], true, false)),
?assertEqual(sfu, voice_p2p:join_decision(false, [], true, true)).
join_decision_for_an_sfu_call_ignores_the_flag_test() ->
Sfu = [peer(<<"a">>, <<"s">>, false)],
?assertEqual(sfu, voice_p2p:join_decision(true, Sfu, false, false)),
?assertEqual(sfu, voice_p2p:join_decision(false, Sfu, false, true)).
admit_test() ->
Grant = {p2p, <<"conn">>, [], 2},
Token = #{token => <<"tok">>},
?assertEqual(Grant, voice_p2p:admit({p2p, initiator, 1}, Grant)),
?assertEqual(Grant, voice_p2p:admit({p2p, joiner, 3}, Grant)),
?assertEqual(sfu, voice_p2p:admit({p2p, initiator, 1}, Token)),
?assertEqual({reject, voice_p2p_unavailable}, voice_p2p:admit({p2p, joiner, 3}, Token)),
?assertEqual(sfu, voice_p2p:admit(sfu, Token)),
?assertEqual({reject, voice_token_failed}, voice_p2p:admit(sfu, Grant)).
admit_maps_a_size_decline_to_channel_full_test() ->
Declined = {declined, voice_channel_full},
?assertEqual({reject, voice_channel_full}, voice_p2p:admit({p2p, joiner, 3}, Declined)),
?assertEqual({reject, voice_channel_full}, voice_p2p:admit({p2p, initiator, 1}, Declined)),
?assertEqual({reject, voice_token_failed}, voice_p2p:admit(sfu, Declined)).
invariant_violation_test() ->
?assertEqual(ok, voice_p2p:invariant_violation([])),
?assertEqual(ok, voice_p2p:invariant_violation(mesh(4))),
?assertEqual({error, voice_channel_full}, voice_p2p:invariant_violation(mesh(5))),
?assertEqual(
{error, voice_p2p_consent_required},
voice_p2p:invariant_violation([peer(<<"a">>, <<"s">>, false) | mesh(1)])
),
?assertEqual(ok, voice_p2p:invariant_violation([peer(<<"a">>, <<"s">>, false)])).
invariant_violation_holds_a_mesh_to_the_configured_cap_test() ->
?assertEqual(ok, voice_p2p:invariant_violation(mesh(2), 2)),
?assertEqual({error, voice_channel_full}, voice_p2p:invariant_violation(mesh(3), 2)),
?assertEqual(ok, voice_p2p:invariant_violation(mesh(4), undefined)),
?assertEqual({error, voice_channel_full}, voice_p2p:invariant_violation(mesh(5), 9)),
?assertEqual(ok, voice_p2p:invariant_violation([peer(<<"a">>, <<"s">>, false)], 1)).
reports_rejection_test() ->
?assert(voice_p2p:reports_rejection(voice_p2p_consent_required)),
?assert(voice_p2p:reports_rejection(voice_p2p_unavailable)),
?assert(voice_p2p:reports_rejection(voice_channel_full)),
?assertNot(voice_p2p:reports_rejection(voice_token_failed)).
to_sfu_clears_the_flag_and_bumps_the_version_test() ->
VoiceState = (peer(<<"a">>, <<"s">>, true))#{<<"version">> => 3},
Converted = voice_p2p:to_sfu(VoiceState),
?assertEqual(false, maps:get(<<"p2p">>, Converted)),
?assertEqual(4, maps:get(<<"version">>, Converted)).
add_to_token_request_test() ->
Request = #{<<"type">> => <<"voice_get_token">>},
?assertEqual(Request, voice_p2p:add_to_token_request(Request, sfu, <<"SE">>)),
?assertEqual(
Request#{
<<"p2p">> => true,
<<"p2p_initiator">> => true,
<<"p2p_participant_count">> => 1,
<<"country_code">> => <<"SE">>
},
voice_p2p:add_to_token_request(Request, {p2p, initiator, 1}, <<"SE">>)
),
?assertEqual(
Request#{
<<"p2p">> => true, <<"p2p_initiator">> => false, <<"p2p_participant_count">> => 3
},
voice_p2p:add_to_token_request(Request, {p2p, joiner, 3}, undefined)
).
grant_reads_a_p2p_grant_test() ->
Stun = #{<<"urls">> => [<<"stun:stun.example:3478">>], <<"ignored">> => true},
Data = #{<<"p2p">> => true, <<"connectionId">> => <<"conn-1">>, <<"iceServers">> => [Stun]},
?assertEqual(
{p2p, <<"conn-1">>, [#{<<"urls">> => [<<"stun:stun.example:3478">>]}], 4},
voice_p2p:grant(Data)
).
grant_reads_the_configured_cap_test() ->
Data = #{<<"p2p">> => true, <<"connectionId">> => <<"conn-1">>, <<"iceServers">> => []},
?assertEqual(
{p2p, <<"conn-1">>, [], 3}, voice_p2p:grant(Data#{<<"maxParticipants">> => 3})
),
?assertEqual(
{p2p, <<"conn-1">>, [], 4}, voice_p2p:grant(Data#{<<"maxParticipants">> => 9})
),
?assertEqual(
{p2p, <<"conn-1">>, [], 4}, voice_p2p:grant(Data#{<<"maxParticipants">> => 0})
).
grant_keeps_only_urls_of_each_ice_server_test() ->
Server = #{
<<"urls">> => [<<"stun:stun.example:3478">>],
<<"username">> => <<"user">>,
<<"credential">> => <<"secret">>
},
Data = #{
<<"p2p">> => true, <<"connectionId">> => <<"conn-1">>, <<"iceServers">> => [Server]
},
?assertEqual(
{p2p, <<"conn-1">>, [#{<<"urls">> => [<<"stun:stun.example:3478">>]}], 4},
voice_p2p:grant(Data)
).
grant_reads_a_size_decline_test() ->
?assertEqual(
{declined, voice_channel_full},
voice_p2p:grant(#{<<"p2p">> => false, <<"declineReason">> => <<"channel_full">>})
),
?assertEqual(
sfu, voice_p2p:grant(#{<<"p2p">> => false, <<"declineReason">> => <<"unknown">>})
).
grant_reads_a_token_response_as_sfu_test() ->
?assertEqual(
sfu,
voice_p2p:grant(#{
<<"token">> => <<"tok">>,
<<"endpoint">> => <<"wss://voice.example">>,
<<"connectionId">> => <<"conn-1">>
})
).
voice_server_update_shape_test() ->
IceServers = [#{<<"urls">> => [<<"stun:stun.example:3478">>]}],
?assertEqual(
#{
<<"p2p">> => true,
<<"ice_servers">> => IceServers,
<<"connection_id">> => <<"conn-1">>,
<<"channel_id">> => <<"100">>,
<<"guild_id">> => <<"999">>
},
voice_p2p:voice_server_update(<<"conn-1">>, 100, 999, IceServers)
),
?assertNot(
maps:is_key(
<<"guild_id">>, voice_p2p:voice_server_update(<<"conn-1">>, 100, null, IceServers)
)
).
signal_route_allows_a_mesh_peer_test() ->
[Sender, Target] = mesh(2),
?assertEqual({ok, Sender, Target}, voice_p2p:signal_route(mesh(2), <<"sess-1">>, <<"2">>)).
signal_route_denies_a_sender_outside_the_mesh_test() ->
?assertEqual(error, voice_p2p:signal_route(mesh(2), <<"sess-9">>, <<"2">>)).
signal_route_denies_an_unknown_target_test() ->
?assertEqual(error, voice_p2p:signal_route(mesh(2), <<"sess-1">>, <<"9">>)).
signal_route_denies_the_senders_own_connection_test() ->
?assertEqual(error, voice_p2p:signal_route(mesh(2), <<"sess-1">>, <<"1">>)).
signal_route_denies_sfu_voice_states_test() ->
Sfu = [peer(<<"1">>, <<"sess-1">>, false), peer(<<"2">>, <<"sess-2">>, false)],
?assertEqual(error, voice_p2p:signal_route(Sfu, <<"sess-1">>, <<"2">>)),
Mixed = [peer(<<"1">>, <<"sess-1">>, true), peer(<<"2">>, <<"sess-2">>, false)],
?assertEqual(error, voice_p2p:signal_route(Mixed, <<"sess-1">>, <<"2">>)).
signal_payload_shape_test() ->
[Sender | _] = mesh(1),
Data = #{<<"type">> => <<"offer">>, <<"sdp">> => <<"v=0">>},
?assertEqual(
#{
<<"guild_id">> => <<"999">>,
<<"channel_id">> => <<"100">>,
<<"from">> => <<"1">>,
<<"user_id">> => <<"10">>,
<<"data">> => Data
},
voice_p2p:signal_payload(999, 100, Sender, Data)
),
?assertNot(maps:is_key(<<"guild_id">>, voice_p2p:signal_payload(null, 100, Sender, Data))).