Files
EventHubBack/test/api/admins/admin_websocket_tests.erl
T

332 lines
13 KiB
Erlang
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
%%%-------------------------------------------------------------------
%%% @doc Тесты WebSocket API (пользовательские и административные).
%%%
%%% Покрывает эндпоинты:
%%% ws://localhost:8081/ws
%%% ws://localhost:8446/admin/ws
%%% ws://localhost:8446/admin/ws/metrics
%%%
%%% Проверяет:
%%% - подключение с валидным пользовательским токеном
%%% - подписку на календарь и получение подтверждения
%%% - подключение с валидным админским токеном
%%% - подписку на каналы отчётов и тикетов
%%% - получение уведомления о создании отчёта
%%% - Ping/Pong
%%% - отписку от канала
%%% - получение метрик узла через /admin/ws/metrics
%%% - отклонение при использовании пользовательского токена для админского сокета
%%% - отклонение при невалидном токене
%%% @end
%%%-------------------------------------------------------------------
-module(admin_websocket_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin WebSocket Tests ==="),
application:ensure_all_started(gun),
AdminToken = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём календарь и событие для тестов
#{<<"id">> := CalId} = api_test_runner:client_post(
<<"/v1/calendars">>, UserToken,
#{title => <<"WS Test Calendar">>, type => <<"commercial">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, UserToken,
#{title => <<"WS Test Event">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60}),
WsUrl = api_test_runner:get_base_ws_url() ++ "/ws",
AdminWsUrl = api_test_runner:get_admin_ws_url() ++ "/admin/ws",
MetricsWsUrl = api_test_runner:get_admin_ws_url() ++ "/admin/ws/metrics",
% ── Пользовательские WebSocket тесты ──
{ok, UserWS} = test_user_ws_connect(WsUrl, UserToken),
test_user_ws_subscribe(UserWS, CalId),
test_ws_close(UserWS),
ct:pal("~n✅ User WebSocket tests passed!"),
% ── Административные WebSocket тесты ──
{ok, AdminWS} = test_admin_ws_connect(AdminWsUrl, AdminToken),
test_admin_ws_subscribe_reports(AdminWS),
test_admin_ws_subscribe_tickets(AdminWS),
test_admin_ws_report_notification(AdminWS, UserToken, EventId),
test_admin_ws_ping(AdminWS),
test_admin_ws_unsubscribe(AdminWS),
test_ws_close(AdminWS),
% ── Метрики узлов ──
test_admin_ws_node_metrics(MetricsWsUrl, AdminToken),
% ── Негативные тесты ──
test_admin_ws_user_token_rejected(AdminWsUrl, UserToken),
test_admin_ws_invalid_token_rejected(AdminWsUrl),
ct:pal("~n=== All admin WebSocket tests passed! ==="),
{?MODULE, ok}.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Подключение пользовательского WebSocket с валидным токеном.
-spec test_user_ws_connect(string(), binary()) -> {ok, pid()}.
test_user_ws_connect(Url, Token) ->
ct:pal(" TEST: Connect user WebSocket with valid token"),
case test_ws_connect_debug(Url, Token) of
{ok, WS} -> {ok, WS};
Other -> error({unexpected, Other})
end.
%% @doc Подписка на календарь и получение подтверждения.
-spec test_user_ws_subscribe(pid(), binary()) -> ok.
test_user_ws_subscribe(WS, CalId) ->
ct:pal(" TEST: Subscribe to calendar"),
SubMsg = #{action => <<"subscribe">>,
calendar_id => CalId},
ok = test_ws_send(WS, SubMsg),
case test_ws_recv(WS) of
{ok, #{<<"status">> := <<"subscribed">>}} -> ok;
{ok, Other} -> error({unexpected_response, Other});
{error, timeout} -> error(timeout)
end.
%% @doc Подключение административного WebSocket.
-spec test_admin_ws_connect(string(), binary()) -> {ok, pid()}.
test_admin_ws_connect(Url, Token) ->
ct:pal(" TEST: Connect admin WebSocket"),
case test_ws_connect_debug(Url, Token) of
{ok, WS} -> {ok, WS};
Other -> error({unexpected, Other})
end.
%% @doc Подписка на канал reports.
-spec test_admin_ws_subscribe_reports(pid()) -> ok.
test_admin_ws_subscribe_reports(WS) ->
ct:pal(" TEST: Admin subscribe to reports"),
ok = test_ws_send(WS, #{action => <<"subscribe">>, channel => <<"reports">>}),
{ok, #{<<"status">> := <<"subscribed">>}} = test_ws_recv(WS).
%% @doc Подписка на канал tickets.
-spec test_admin_ws_subscribe_tickets(pid()) -> ok.
test_admin_ws_subscribe_tickets(WS) ->
ct:pal(" TEST: Admin subscribe to tickets"),
ok = test_ws_send(WS, #{action => <<"subscribe">>, channel => <<"tickets">>}),
{ok, #{<<"status">> := <<"subscribed">>}} = test_ws_recv(WS).
%% @doc Получение уведомления о создании отчёта.
-spec test_admin_ws_report_notification(pid(), binary(), binary()) -> ok.
test_admin_ws_report_notification(WS, UserToken, EventId) ->
ct:pal(" TEST: Admin receives report notification"),
api_test_runner:client_post(<<"/v1/reports">>, UserToken,
#{target_type => <<"event">>, target_id => EventId, reason => <<"Test">>}),
{ok, #{<<"type">> := <<"report_created">>}} = test_ws_recv(WS, 5000).
%% @doc Ping/Pong.
-spec test_admin_ws_ping(pid()) -> ok.
test_admin_ws_ping(WS) ->
ct:pal(" TEST: Admin Ping/Pong"),
ok = test_ws_send(WS, #{action => <<"ping">>}),
{ok, #{<<"status">> := <<"pong">>}} = test_ws_recv(WS).
%% @doc Отписка от канала reports.
-spec test_admin_ws_unsubscribe(pid()) -> ok.
test_admin_ws_unsubscribe(WS) ->
ct:pal(" TEST: Admin unsubscribe from reports"),
ok = test_ws_send(WS, #{action => <<"unsubscribe">>, channel => <<"reports">>}),
{ok, #{<<"status">> := <<"unsubscribed">>}} = test_ws_recv(WS).
%% @doc Получение метрик узла через /admin/ws/metrics.
-spec test_admin_ws_node_metrics(string(), binary()) -> ok.
test_admin_ws_node_metrics(Url, Token) ->
ct:pal(" TEST: Admin WS receive node metrics"),
{ok, WS} = test_ws_connect_debug(Url, Token),
ok = test_ws_send(WS, #{action => <<"ping">>}),
{ok, #{<<"status">> := <<"pong">>}} = test_ws_recv(WS, 3000),
ct:pal(" Ping/Pong OK waiting for node metric..."),
case test_ws_recv(WS, 15000) of
{ok, #{<<"type">> := <<"node_metric">>, <<"data">> := MetricData}} ->
ct:pal(" OK - Received node metric"),
?assert(is_map(MetricData)),
?assert(maps:is_key(<<"timestamp">>, MetricData)),
?assert(maps:is_key(<<"node">>, MetricData)),
?assert(maps:is_key(<<"memory_total">>, MetricData)),
ct:pal(" Node: ~s, Memory: ~p MB",
[maps:get(<<"node">>, MetricData),
maps:get(<<"memory_total">>, MetricData) div 1048576]);
{error, timeout} ->
ct:pal(" WARNING: No node metric received (timeout) check monitor logs");
Other ->
ct:pal(" ERROR: Unexpected response: ~p", [Other]),
?assert(false, {unexpected_ws_message, Other})
end,
test_ws_close(WS).
%% @doc Отклонение при использовании пользовательского токена для админского сокета.
-spec test_admin_ws_user_token_rejected(string(), binary()) -> ok.
test_admin_ws_user_token_rejected(Url, Token) ->
ct:pal(" TEST: Admin WS with user token rejected"),
?assertMatch({error, {403, _}}, test_ws_connect_debug(Url, Token)).
%% @doc Отклонение при невалидном токене.
-spec test_admin_ws_invalid_token_rejected(string()) -> ok.
test_admin_ws_invalid_token_rejected(Url) ->
ct:pal(" TEST: Admin WS with invalid token rejected"),
Chars = <<"abcdefghijklmnopqrstuvwxyz0123456789">>,
InvalidToken = << <<(binary:at(Chars, rand:uniform(byte_size(Chars)) - 1))>>
|| _ <- lists:seq(1, 30) >>,
?assertMatch({error, {401, _}}, test_ws_connect_debug(Url, InvalidToken)).
%% ============ WebSocket хелперы с отладкой ============
test_ws_connect_debug(Url, Token) ->
Path = case string:split(Url, "://", trailing) of
[_, Rest] ->
case string:split(Rest, "/", leading) of
[_HostPort, WsPath] ->
"/" ++ WsPath ++ "?token=" ++ binary_to_list(Token);
_ ->
"/ws?token=" ++ binary_to_list(Token)
end;
_ ->
"/ws?token=" ++ binary_to_list(Token)
end,
{ok, Port} = extract_port(Url),
{ok, Host} = extract_host(Url),
Opts = case Port of
443 -> #{protocols => [http],
transport => tls,
tls_opts => [{verify, verify_none}]};
_ -> #{protocols => [http]}
end,
ct:pal(" Host: ~s", [Host]),
ct:pal(" Port: ~p", [Port]),
ct:pal(" Path: ~s", [Path]),
{ok, ConnPid} = gun:open(Host, Port, Opts),
{ok, http} = gun:await_up(ConnPid, 5000),
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
receive
{gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} ->
ct:pal(" WebSocket upgrade OK"),
{ok, ConnPid};
{gun_response, ConnPid, StreamRef, fin, 401, _} ->
ct:pal(" ERROR: HTTP 401 Unauthorized"),
gun:close(ConnPid),
{error, {401, <<"Invalid token">>}};
{gun_response, ConnPid, StreamRef, fin, 403, _} ->
ct:pal(" ERROR: HTTP 403 Forbidden"),
gun:close(ConnPid),
{error, {403, <<"Admin access required">>}};
{gun_response, ConnPid, StreamRef, nofin, 403, _} ->
ct:pal(" ERROR: HTTP 403 Forbidden (nofin)"),
gun:close(ConnPid),
{error, {403, <<"Admin access required">>}};
{gun_response, ConnPid, StreamRef, fin, Status, _} ->
ct:pal(" ERROR: HTTP ~p", [Status]),
gun:close(ConnPid),
{error, {Status, <<"WebSocket upgrade failed">>}};
{gun_response, ConnPid, StreamRef, nofin, Status, _} ->
ct:pal(" ERROR: HTTP ~p (nofin)", [Status]),
gun:close(ConnPid),
{error, {Status, <<"WebSocket upgrade failed">>}};
{gun_error, ConnPid, Reason} ->
ct:pal(" ERROR: ~p", [Reason]),
gun:close(ConnPid),
{error, Reason}
after 5000 ->
ct:pal(" ERROR: Timeout"),
gun:close(ConnPid),
{error, timeout}
end.
test_ws_send(ConnPid, Data) ->
Msg = jsx:encode(Data),
ct:pal(" Sending: ~s", [Msg]),
case catch gun:ws_send(ConnPid, {text, Msg}) of
ok -> ok;
{'EXIT', {undef, _}} ->
gun:ws_send(ConnPid, fin, {text, Msg});
Other ->
ct:pal(" ERROR sending: ~p", [Other]),
error({ws_send_failed, Other})
end.
test_ws_recv(ConnPid) ->
test_ws_recv(ConnPid, 3000).
test_ws_recv(ConnPid, Timeout) ->
receive
{gun_ws, ConnPid, _StreamRef, {text, Msg}} ->
ct:pal(" Received (with StreamRef): ~s", [Msg]),
{ok, jsx:decode(Msg, [return_maps])};
{gun_ws, ConnPid, {text, Msg}} ->
ct:pal(" Received: ~s", [Msg]),
{ok, jsx:decode(Msg, [return_maps])};
{gun_ws, ConnPid, _StreamRef, Frame} ->
ct:pal(" Received frame: ~p", [Frame]),
{ok, Frame};
{gun_ws, ConnPid, Frame} ->
ct:pal(" Received: ~p", [Frame]),
{ok, Frame};
{gun_error, ConnPid, Reason} ->
ct:pal(" ERROR: gun_error ~p", [Reason]),
{error, Reason};
Other ->
ct:pal(" Received unexpected: ~p", [Other]),
test_ws_recv(ConnPid, Timeout)
after Timeout ->
{error, timeout}
end.
test_ws_close(ConnPid) ->
gun:close(ConnPid).
%% ========== URL parsing helpers ==========
extract_port(Url) ->
case string:split(Url, "://", trailing) of
[_, Rest] ->
HostPort = case string:split(Rest, "/", leading) of
[H, _] -> H;
[H] -> H
end,
case string:split(HostPort, ":", trailing) of
[_, PortStr] -> {ok, list_to_integer(PortStr)};
_ -> case string:split(Rest, "://", trailing) of
[_, R] -> extract_port("https://" ++ R);
_ -> {ok, default_port(Url)}
end
end;
_ -> {ok, default_port(Url)}
end.
default_port(Url) ->
case string:prefix(Url, "wss://") of
nomatch -> case string:prefix(Url, "ws://") of
nomatch -> 80;
_ -> 80
end;
_ -> 443
end.
extract_host(Url) ->
case string:split(Url, "://", trailing) of
[_, Rest] ->
HostPort = case string:split(Rest, "/", leading) of
[H, _] -> H;
[H] -> H
end,
case string:split(HostPort, ":", trailing) of
[Host, _] -> {ok, Host};
[Host] -> {ok, Host}
end;
_ -> {ok, "localhost"}
end.