fix(backend): IP в аудите, closed_at тикетов и admin_activity в статистике. Refs EventHub/EventHubBack#27 #28 #29
CI / test (push) Successful in 15m13s
Deploy stage (core) / deploy-stage-core (push) Successful in 4m57s

This commit is contained in:
2026-07-13 14:53:51 +03:00
parent 4281342416
commit ff4ab23264
12 changed files with 320 additions and 38 deletions
+18
View File
@@ -42,6 +42,7 @@ test() ->
% ── Основные тесты ──
test_list_audit(SuperToken),
test_list_admin_forbidden(AdminToken),
test_login_audit_ip(SuperToken),
% ── Фильтрация ──
test_filter_audit_by_admin_id(SuperToken, AdminId),
@@ -85,6 +86,23 @@ test_list_admin_forbidden(Token) ->
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% @doc Логин с X-Forwarded-For записывает IP клиента в аудит.
test_login_audit_ip(Token) ->
ct:pal(" TEST: Login audit stores X-Forwarded-For IP"),
Email = api_test_runner:admin_super_email(),
Password = api_test_runner:admin_super_password(),
Body = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}),
{ok, 200, _, _} = api_test_runner:admin_request(
post, <<"/v1/admin/login">>, <<>>, Body,
[{"X-Forwarded-For", "203.0.113.99"}]
),
Records = api_test_runner:admin_get(<<"/v1/admin/audit?action=login">>, Token),
Forwarded = [R || R <- Records, maps:get(<<"ip">>, R) =:= <<"203.0.113.99">>],
?assert(length(Forwarded) >= 1),
Login = hd(Forwarded),
?assertEqual(<<"203.0.113.99">>, maps:get(<<"ip">>, Login)),
ct:pal(" OK: ip=~s", [maps:get(<<"ip">>, Login)]).
%% ── Фильтрация ──
%% @doc Фильтрация по admin_id.
+35 -1
View File
@@ -70,6 +70,8 @@ test() ->
test_stats_with_dates(SuperToken),
test_node_metrics_history(SuperToken),
test_avg_ticket_resolution_after_close(SuperToken, UserToken),
test_admin_activity(SuperToken),
% Детальная статистика
test_user_stats(SuperToken),
@@ -116,6 +118,7 @@ test_stats_for_role(RoleName, Token, Strictness) ->
?assert(maps:is_key(<<"tickets_by_day">>, Stats)),
?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)),
?assert(maps:is_key(<<"pending_users_by_day">>, Stats)),
?assert(maps:is_key(<<"avg_ticket_resolution_h">>, Stats)),
% Проверка, что они действительно списки
?assert(is_list(maps:get(<<"registrations_by_day">>, Stats))),
?assert(is_list(maps:get(<<"events_by_day">>, Stats))),
@@ -244,4 +247,35 @@ test_node_metrics_history(Token) ->
?assert(maps:is_key(<<"memory_total">>, First)),
?assert(maps:is_key(<<"memory_available">>, First)),
?assert(maps:is_key(<<"cpu_utilization">>, First)),
ct:pal(" OK: ~p metrics received", [length(Metrics)]).
ct:pal(" OK: ~p metrics received", [length(Metrics)]).
test_avg_ticket_resolution_after_close(Token, UserToken) ->
ct:pal(" TEST: avg_ticket_resolution_h after closing ticket"),
Ticket = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
#{<<"error_message">> => <<"Resolution metric">>, <<"stacktrace">> => <<"trace">>}),
#{<<"id">> := TicketId} = Ticket,
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
timer:sleep(20000),
Closed = api_test_runner:admin_put(Path, Token, #{<<"status">> => <<"closed">>}),
?assertEqual(<<"closed">>, maps:get(<<"status">>, Closed)),
?assertNotEqual(<<"1970-01-01T00:00:00Z">>, maps:get(<<"closed_at">>, Closed)),
Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token),
Avg = maps:get(<<"avg_ticket_resolution_h">>, Stats),
?assert(is_number(Avg)),
?assert(Avg > 0.0),
ct:pal(" OK: avg_ticket_resolution_h=~p", [Avg]).
test_admin_activity(Token) ->
ct:pal(" TEST: admin_activity in dashboard stats"),
Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token),
Activity = maps:get(<<"admin_activity">>, Stats),
?assert(is_list(Activity)),
?assert(length(Activity) >= 1),
First = hd(Activity),
?assert(maps:is_key(<<"admin_id">>, First)),
?assert(maps:is_key(<<"email">>, First)),
?assert(maps:is_key(<<"role">>, First)),
?assert(maps:is_key(<<"actions">>, First)),
?assert(maps:is_key(<<"last_login">>, First)),
?assert(maps:get(<<"actions">>, First) >= 1),
ct:pal(" OK: ~p admin activity rows", [length(Activity)]).
+21 -8
View File
@@ -23,10 +23,13 @@
register_and_login/2,
create_calendar/2,
create_event/3
, get_admin_refresh_token/0]).
, get_admin_refresh_token/0
, admin_super_email/0
, admin_super_password/0]).
-export([
admin_request/3,
admin_request/4,
admin_request/5,
client_request/3,
client_request/4
]).
@@ -175,7 +178,12 @@ admin_request(Method, Path, Token) ->
-spec admin_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
admin_request(Method, Path, Token, Body) ->
request(get_admin_url(), Method, Path, Token, Body, "ADMIN").
admin_request(Method, Path, Token, Body, []).
-spec admin_request(atom(), binary(), binary(), binary(), [{string(), string()}]) ->
{ok, integer(), proplists:proplist(), binary()} | {error, term()}.
admin_request(Method, Path, Token, Body, ExtraHeaders) ->
request(get_admin_url(), Method, Path, Token, Body, "ADMIN", ExtraHeaders).
-spec client_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
client_request(Method, Path, Token) ->
@@ -183,7 +191,7 @@ client_request(Method, Path, Token) ->
-spec client_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
client_request(Method, Path, Token, Body) ->
request(get_base_url(), Method, Path, Token, Body, "CLIENT").
request(get_base_url(), Method, Path, Token, Body, "CLIENT", []).
%%%===================================================================
%%% Внутренняя реализация HTTP-запроса
@@ -191,12 +199,17 @@ client_request(Method, Path, Token, Body) ->
-spec request(string(), atom(), binary(), binary(), binary(), string()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
request(BaseUrl, Method, Path, Token, Body, Prefix) ->
request(BaseUrl, Method, Path, Token, Body, Prefix, []).
-spec request(string(), atom(), binary(), binary(), binary(), string(), [{string(), string()}]) ->
{ok, integer(), proplists:proplist(), binary()} | {error, term()}.
request(BaseUrl, Method, Path, Token, Body, Prefix, ExtraHeaders) ->
URL = BaseUrl ++ binary_to_list(Path),
Headers0 = [],
Headers = case Token of
<<>> -> Headers0; % пустой токен – не добавляем Authorization
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
AuthHeaders = case Token of
<<>> -> [];
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
Headers = ExtraHeaders ++ AuthHeaders,
ct:pal("~s REQUEST: ~s ~s", [Prefix, Method, URL]),
RequestArg = case Method of
get -> {URL, Headers};
+37
View File
@@ -0,0 +1,37 @@
-module(admin_utils_tests).
-include_lib("eunit/include/eunit.hrl").
client_ip_test_() ->
{setup,
fun() -> ok = meck:new(cowboy_req, [non_strict]) end,
fun(_) -> meck:unload(cowboy_req) end,
[
{"Uses X-Forwarded-For when present", fun test_forwarded_for/0},
{"Falls back to X-Real-IP", fun test_real_ip/0},
{"Falls back to peer address", fun test_peer_fallback/0},
{"ip_to_binary handles IPv4 tuple", fun test_ipv4_tuple/0}
]}.
test_forwarded_for() ->
ok = meck:expect(cowboy_req, header, fun
(<<"x-forwarded-for">>, _) -> <<"203.0.113.10, 10.0.0.1">>;
(_, _) -> undefined
end),
ok = meck:expect(cowboy_req, peer, fun(_) -> {10, 0, 0, 2} end),
?assertEqual(<<"203.0.113.10">>, admin_utils:client_ip(req)).
test_real_ip() ->
ok = meck:expect(cowboy_req, header, fun
(<<"x-real-ip">>, _) -> <<"198.51.100.5">>;
(_, _) -> undefined
end),
ok = meck:expect(cowboy_req, peer, fun(_) -> {10, 0, 0, 2} end),
?assertEqual(<<"198.51.100.5">>, admin_utils:client_ip(req)).
test_peer_fallback() ->
ok = meck:expect(cowboy_req, header, fun(_, _) -> undefined end),
ok = meck:expect(cowboy_req, peer, fun(_) -> {127, 0, 0, 1} end),
?assertEqual(<<"127.0.0.1">>, admin_utils:client_ip(req)).
test_ipv4_tuple() ->
?assertEqual(<<"192.168.1.1">>, admin_utils:ip_to_binary({192, 168, 1, 1})).
+15 -10
View File
@@ -15,20 +15,24 @@ setup() ->
{attributes, record_info(fields, user)}, {ram_copies, [node()]}]),
{atomic, ok} = mnesia:create_table(ticket, [
{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]),
% Создаём админа и обычного пользователя
Admin = #user{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>,
role = admin, status = active,
created_at = calendar:universal_time(), updated_at = calendar:universal_time()},
{atomic, ok} = mnesia:create_table(admin, [
{attributes, record_info(fields, admin)}, {ram_copies, [node()]}]),
Now = calendar:universal_time(),
Admin = #admin{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>,
role = admin, status = active, nickname = <<>>, avatar_url = default,
timezone = <<"UTC">>, language = <<"ru">>, phone = <<>>,
preferences = #{}, last_login = Now, created_at = Now, updated_at = Now},
User = #user{id = <<"user1">>, email = <<"u@u.u">>, password_hash = <<"h">>,
role = user, status = active,
created_at = calendar:universal_time(), updated_at = calendar:universal_time()},
created_at = Now, updated_at = Now},
mnesia:dirty_write(Admin),
mnesia:dirty_write(User),
ok.
cleanup(_) ->
mnesia:delete_table(user),
mnesia:delete_table(ticket),
catch mnesia:delete_table(admin),
catch mnesia:delete_table(user),
catch mnesia:delete_table(ticket),
mnesia:stop().
%% ----------------------------------------------------------------
@@ -49,7 +53,7 @@ logic_ticket_test_() ->
%% --- Вспомогательная функция для создания тикета ---
report(ErrorMsg) ->
logic_ticket:report_error(ErrorMsg, <<"stack">>, #{}).
logic_ticket:report_error(ErrorMsg, <<"stack">>, #{<<"reporter_id">> => <<"user1">>}).
%% --- Тесты ---
@@ -87,13 +91,14 @@ test_assign_ticket() ->
test_resolve_ticket() ->
{ok, Ticket} = report(<<"E4">>),
{ok, Updated} = logic_ticket:resolve_ticket(<<"admin1">>, Ticket#ticket.id, <<"Fixed">>),
?assertEqual(closed, Updated#ticket.status),
?assertEqual(resolved, Updated#ticket.status),
?assertEqual(<<"Fixed">>, Updated#ticket.resolution_note).
test_close_ticket() ->
{ok, Ticket} = report(<<"E5">>),
{ok, Updated} = logic_ticket:close_ticket(<<"admin1">>, Ticket#ticket.id),
?assertEqual(closed, Updated#ticket.status).
?assertEqual(closed, Updated#ticket.status),
?assertNotEqual({{1970, 1, 1}, {0, 0, 0}}, Updated#ticket.closed_at).
test_get_statistics() ->
{ok, _} = report(<<"E6">>),