fix: tighten edge cases across services (#3158)

This commit is contained in:
Hampus
2026-10-03 13:04:29 +02:00
committed by GitHub
parent a9f7a23c0d
commit 4e6b837ccc
170 changed files with 2028 additions and 421 deletions
@@ -0,0 +1,268 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_voice_access_revoke_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(GUILD, 42).
-define(USER, 10).
-define(CHANNEL, 500).
-define(ROLE, 999).
overwrite_deny_view_connect_disconnects_connected_user_test() ->
State = guild_state:update_state(channel_update, deny_overwrite_update(), base_state(true)),
Messages = collect_sync_messages(),
?assert(disconnected(Messages)),
?assertNot(granted_permissions(Messages)),
?assert(guild_virtual_channel_access:has_virtual_access(?USER, ?CHANNEL, State)),
?assertNot(guild_virtual_channel_access:has_voice_access(?USER, ?CHANNEL, State)).
overwrite_deny_connect_only_disconnects_connected_user_test() ->
Update = channel_update_with_user_deny(constants:connect_permission()),
_ = guild_state:update_state(channel_update, Update, base_state(true)),
?assert(disconnected(collect_sync_messages())).
overwrite_deny_view_connect_disconnects_user_without_session_test() ->
_ = guild_state:update_state(channel_update, deny_overwrite_update(), base_state(false)),
?assert(disconnected(collect_sync_messages())).
role_removal_disconnects_connected_user_test() ->
Update = #{<<"user">> => #{<<"id">> => integer_to_binary(?USER)}, <<"roles">> => []},
_ = guild_state:update_state(guild_member_update, Update, base_state(true)),
Messages = collect_sync_messages(),
?assert(disconnected(Messages)),
?assertNot(granted_permissions(Messages)).
moderator_granted_access_survives_revoke_test() ->
State0 = guild_virtual_channel_access:add_virtual_access(?USER, ?CHANNEL, base_state(true)),
State = guild_state:update_state(channel_update, deny_overwrite_update(), State0),
Messages = collect_sync_messages(),
?assertNot(disconnected(Messages)),
?assert(guild_virtual_channel_access:has_voice_access(?USER, ?CHANNEL, State)).
view_only_access_grants_no_publish_rights_test() ->
ViewConnect = constants:view_channel_permission() bor constants:connect_permission(),
State0 = with_role_permissions(ViewConnect, base_state(true)),
State1 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL, State0),
State = with_overwrites([user_deny(ViewConnect)], State1),
?assertEqual(
#{can_speak => false, can_stream => false, can_video => false},
voice_utils:compute_voice_permissions(?USER, ?CHANNEL, State)
).
explicit_access_replaces_view_only_mark_test() ->
State0 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL, #{}),
?assert(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL, State0)),
State1 = guild_virtual_channel_access:add_virtual_access(?USER, ?CHANNEL, State0),
?assertNot(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL, State1)),
?assert(guild_virtual_channel_access:has_voice_access(?USER, ?CHANNEL, State1)).
removing_access_clears_view_only_mark_test() ->
State0 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL, #{}),
State1 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL + 1, State0),
State2 = guild_virtual_channel_access:remove_virtual_access(?USER, ?CHANNEL, State1),
?assertNot(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL, State2)),
?assert(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL + 1, State2)),
State3 = guild_virtual_channel_access:remove_virtual_access(?USER, ?CHANNEL + 1, State2),
?assertNot(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL + 1, State3)).
channel_delete_disconnects_everyone_in_channel_test() ->
Self = self(),
VoicePid = spawn(fun() ->
receive
Message -> Self ! {voice_server_got, Message}
end
end),
State0 = (base_state(true))#{voice_server_pid => VoicePid},
_ = guild_state:update_state(
channel_delete,
#{<<"id">> => integer_to_binary(?CHANNEL), <<"type">> => 2},
State0
),
receive
{voice_server_got, {'$gen_cast', {disconnect_all_voice_users_in_channel, Request}}} ->
?assertEqual(#{channel_id => ?CHANNEL}, Request)
after 500 ->
exit(VoicePid, kill),
?assert(false)
end.
voice_server_disconnects_all_users_in_channel_test() ->
Self = self(),
ForceFun = fun(GId, ChId, UId, ConnId) ->
Self ! {force_disconnect, GId, ChId, UId, ConnId},
{ok, #{success => true}}
end,
GuildPid = spawn(fun() -> guild_state_reply_loop(ForceFun) end),
State = #{
guild_id => ?GUILD,
guild_pid => GuildPid,
voice_states => #{
<<"conn-a">> => voice_state(<<"conn-a">>, 5, ?CHANNEL),
<<"conn-b">> => voice_state(<<"conn-b">>, 6, ?CHANNEL),
<<"conn-c">> => voice_state(<<"conn-c">>, 7, ?CHANNEL + 1)
},
pending_voice_connections => #{},
recently_disconnected_voice_states => #{},
e2ee_room_keys => #{}
},
try
{noreply, NewState} = guild_voice_server:handle_cast(
{disconnect_all_voice_users_in_channel, #{channel_id => ?CHANNEL}}, State
),
?assertEqual([<<"conn-c">>], maps:keys(maps:get(voice_states, NewState))),
Disconnected = lists:sort([receive_force_disconnect(), receive_force_disconnect()]),
?assertEqual(
[{?GUILD, ?CHANNEL, 5, <<"conn-a">>}, {?GUILD, ?CHANNEL, 6, <<"conn-b">>}],
Disconnected
)
after
exit(GuildPid, kill)
end.
receive_force_disconnect() ->
receive
{force_disconnect, GId, ChId, UId, ConnId} -> {GId, ChId, UId, ConnId}
after 500 -> erlang:error(missing_force_disconnect)
end.
guild_state_reply_loop(ForceFun) ->
GuildState = #{
id => ?GUILD,
data => #{<<"guild">> => #{<<"owner_id">> => <<"1">>}},
sessions => #{},
test_force_disconnect_fun => ForceFun
},
receive
{'$gen_call', From, {get_voice_guild_state}} ->
gen_server:reply(From, GuildState),
guild_state_reply_loop(ForceFun);
_ ->
guild_state_reply_loop(ForceFun)
end.
voice_state(ConnId, UserId, ChannelId) ->
#{
<<"guild_id">> => integer_to_binary(?GUILD),
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"connection_id">> => ConnId
}.
base_state(WithSession) ->
ok = drain(),
Self = self(),
SyncFun = fun(GId, ChId, UId, ConnId, Perms) ->
Self ! {synced, GId, ChId, UId, ConnId, Perms}
end,
Sessions =
case WithSession of
true ->
#{
<<"sess">> => #{
session_id => <<"sess">>,
user_id => ?USER,
pid => spawn(fun sink/0),
viewable_channels => #{?CHANNEL => true}
}
};
false ->
#{}
end,
RolePerms =
constants:view_channel_permission() bor constants:connect_permission() bor
constants:speak_permission(),
#{
id => ?GUILD,
sessions => Sessions,
voice_states => #{
<<"conn">> => (voice_state(<<"conn">>, ?USER, ?CHANNEL))#{<<"deaf">> => false}
},
member_list_subscriptions => guild_member_list_subs:new(),
test_permission_sync_fun => SyncFun,
data => #{
<<"guild">> => #{<<"owner_id">> => <<"1">>},
<<"roles">> => roles(RolePerms),
<<"members">> => [
#{
<<"user">> => #{<<"id">> => integer_to_binary(?USER)},
<<"roles">> => [integer_to_binary(?ROLE)]
}
],
<<"channels">> => [voice_channel([])]
}
}.
voice_channel(Overwrites) ->
#{
<<"id">> => integer_to_binary(?CHANNEL),
<<"type">> => 2,
<<"permission_overwrites">> => Overwrites
}.
with_overwrites(Overwrites, State) ->
Data = maps:get(data, State),
State#{data => Data#{<<"channels">> => [voice_channel(Overwrites)]}}.
with_role_permissions(Permissions, State) ->
Data = maps:get(data, State),
State#{data => Data#{<<"roles">> => roles(Permissions)}}.
roles(Permissions) ->
[
#{
<<"id">> => integer_to_binary(?ROLE),
<<"permissions">> => integer_to_binary(Permissions)
},
#{<<"id">> => integer_to_binary(?GUILD), <<"permissions">> => <<"0">>}
].
user_deny(Deny) ->
#{
<<"id">> => integer_to_binary(?USER),
<<"type">> => 1,
<<"allow">> => <<"0">>,
<<"deny">> => integer_to_binary(Deny)
}.
channel_update_with_user_deny(Deny) ->
voice_channel([user_deny(Deny)]).
deny_overwrite_update() ->
channel_update_with_user_deny(
constants:view_channel_permission() bor constants:connect_permission()
).
sink() ->
receive
stop -> ok;
_ -> sink()
end.
drain() ->
receive
_ -> drain()
after 0 -> ok
end.
collect_sync_messages() ->
collect_sync_messages([]).
collect_sync_messages(Acc) ->
receive
{synced, _, _, _, _, _} = Message -> collect_sync_messages([Message | Acc])
after 300 -> lists:reverse(Acc)
end.
disconnected(Messages) ->
lists:any(
fun({synced, _, _, _, _, Perms}) -> maps:get(disconnected, Perms, false) =:= true end,
Messages
).
granted_permissions(Messages) ->
lists:any(
fun({synced, _, _, _, _, Perms}) -> maps:is_key(can_speak, Perms) end,
Messages
).